Caching in Next.js 15+: The Four Layers
Next.js has four overlapping caches. Most production bugs are misalignment between them — here's the map.
"Why is my page still showing yesterday's data?" is the most-asked Next.js question. The answer is almost always: one of four caches is in the way. Understanding which one — and which lever flushes it — is the whole game.
1. The four layers
- Request Memoization — per-request, in-memory. Dedupes identical
fetch()calls within a single render. - Data Cache — persistent across requests and deploys. Caches
fetch()responses on the server. - Full Route Cache — the rendered HTML and the RSC payload, per route.
- Router Cache — client-side, in-memory. Caches navigation results so back-button is instant.
They cascade: a request first hits the Router Cache (in the browser), then the Full Route Cache (on the server's CDN/build artefacts), then the Data Cache, then the actual origin.
2. Request Memoization — fix-and-forget
If you call fetch("/api/user") three times in the same render, Next runs it once. This is automatic and de-duplicates within one render only. You almost never need to think about it. Good to know it exists so you stop manually passing data down the tree to "avoid double fetches."
3. Data Cache — where most production bugs live
By default in 15+, fetch() is uncached unless you opt in. Opt-in shapes:
// Force-cache, no revalidation
const res = await fetch(url, { cache: "force-cache" });
// Time-based revalidation
const res = await fetch(url, { next: { revalidate: 3600 } });
// Tag-based, manual invalidation
const res = await fetch(url, { next: { tags: ["products"] } });
And the invalidation tool:
"use server";
import { revalidateTag, revalidatePath } from "next/cache";
export async function publishProduct(id: string) {
await db.product.publish(id);
revalidateTag("products"); // re-fetches anything tagged "products"
}
The mental model: tag your fetches by domain ("products," "user:42"), invalidate by tag in your server actions. Path-based invalidation works but is coarser.
4. Full Route Cache — static vs dynamic
If a route has no dynamic functions (no cookies(), no headers(), no uncached fetch()), it is statically generated at build time and served from the route cache forever (until you redeploy or invalidate).
The moment you call cookies() or an uncached fetch(), the route opts out of static generation and renders dynamically every request. This is usually what you want for personalised content but is the root cause of "I deployed but my static page never changes" — you are not actually dynamic, you are cached.
5. Router Cache — the silent one
When you click a <Link> and navigate, Next caches the result in the browser. Hit the back button: it's instant, served from memory.
This cache is invisible to anything you can do on the server. revalidateTag does not touch it. The user can sit on a stale view for the cache's TTL (5 minutes for layouts, 30 seconds for pages by default).
To force a fresh navigation, use router.refresh() — it bypasses the router cache for the current route.
6. The decision tree
- "My data is stale after I update it" → Data Cache. Call
revalidateTagin the server action that updated. - "My page is the same for every user when it shouldn't be" → Full Route Cache. You forgot to call
cookies()or usenoStore()inside the page. - "Back button shows old data" → Router Cache.
router.refresh()or accept it for the cache TTL. - "My deploy invalidated everything" → Full Route Cache on a new build is wiped, by design.
7. The opt-out hierarchy, fastest to slowest
- Page-level:
export const dynamic = "force-dynamic"— opts the whole route out of all server caches. - Fetch-level:
fetch(url, { cache: "no-store" })— opts that single fetch out. - Function-level:
noStore()inside the component — opts the component out of static rendering.
Use the smallest scope that solves your problem. force-dynamic on a page is a sledgehammer that breaks performance for the 95% of the page that could have been static.
The honest summary
Next.js caching is powerful and unforgiving. The teams I see succeed write down — actually write down — which cache layer governs each surface in their app, and which event invalidates it. The teams that get burned add "force-dynamic" everywhere and wonder why their app is slow.