Caching is the feature everyone loves until it serves something stale. If you've ever shipped a Next.js app and wondered why a page didn't update after deploying, this post is for you.
The trick to staying sane is realizing that "caching in Next.js" is actually four different caches, each with a different job. Once you can name them, debugging becomes straightforward.
The four layers
| Layer | What it caches | Default lifetime |
|---|---|---|
| Request memoization | Duplicate fetches within one render pass | One request |
| Data cache | Fetch results across requests and deploys | Until revalidated |
| Full route cache | Rendered routes (HTML + RSC payload) | Until invalidated |
| Router cache | Visited pages on the client | Session / 30s–5min |
Let's walk through what that means in practice.
Start from the data
Most staleness bugs start one layer up: someone changed the data, but the route was still serving an old prerender. Before reaching for no-store everywhere, ask one question: how fresh must this data be?
For content that changes on deploy — like blog posts or docs read from files at build time — no configuration is needed. Pages built with static generation are perfect:
export const dynamicParams = false;
export async function generateStaticParams() {
return getAllPosts().map((post) => ({ slug: post.slug }));
}
export default async function Page({ params }: PageProps<"/blog/[slug]">) {
const { slug } = await params;
// Read from the filesystem — resolved entirely at build time.
}For data that changes more often than you deploy, reach for time-based revalidation:
const res = await fetch("https://api.example.com/stats", {
next: { revalidate: 3600 }, // refresh at most hourly
});Rule of thumb: pick the slowest freshness your product will tolerate. Every step faster costs you build output, CDN hits, or money.
When the route is the problem
Sometimes the data is fresh but the page isn't. That's the full route cache. Two escape hatches cover almost every case:
- Dynamic rendering — reading cookies, headers or search params opts a route out of prerendering.
- Revalidating after mutations — when data changes through a Server Action, call
revalidateTagso the next request rebuilds the affected pages.
"use server";
import { revalidateTag } from "next/cache";
export async function publishPost(postId: string) {
await db.post.publish(postId);
revalidateTag("posts", "max");
}What I stopped worrying about
- Request memoization just works; deduplicate freely within a render.
- Router cache bugginess from early versions is largely historical now.
- Perfect cache invalidation for user-specific dashboards? Just render dynamically. Static is a tool, not an identity.
Debugging checklist
When something looks stale, work down this list:
- Is it stale for everyone or just me? (Incognito + another device)
- Did the deploy actually rebuild the page? Check the build output.
- Is the fetch cached longer than expected? Look for
revalidate. - Am I reading request-time APIs accidentally? That forces dynamic rendering.
Ninety percent of issues die at step two. The remaining ten percent are usually a Cache-Control header from an upstream API that nobody remembered setting.
Caching rewards people who can articulate what they're caching and why. Name the layer, choose its lifetime deliberately, and move on to building things.