Next.js caching has a bad reputation, and it isn’t undeserved. But the cause is rarely named correctly: the problem isn’t that it’s hard, it’s that the philosophy changed three times in three years. What you learned in 2023 was half-obsolete by 2024 and got reorganized entirely in v16. Every release moved the defaults, and when defaults move, knowledge rots.
So instead of handing you a recipe that expires in the next major, let’s walk the evolution. Understanding where Cache Components —the Next.js 16 model— comes from is the only way it stops looking like magic and starts looking like a decision.
the underlying problem
All caching is the same bargain: you trade freshness for speed. You store a result so you don’t have to compute it again, and in exchange you accept that it might go stale. Next.js didn’t invent that tension and nobody is going to resolve it; the only thing that changes between frameworks is who decides and how visible the decision is.
The App Router’s original bet was ambitious: to automate that bargain. Let the framework infer what to cache and for how long, so you wouldn’t have to think about it. The intent was good. The result was that a lot of people had cached data without knowing why, and fresh data without knowing why. The most repeated complaint of the entire App Router era —“why isn’t my page updating?”— is born right here.
To understand why, you have to open the box. There isn’t one cache in Next.js: there are four.
the four caches (the classic model, v13–v14)
When the App Router shipped, Next.js stacked four different caches, each at a different layer of a request’s journey. The confusion came from almost nobody knowing there were four: you experienced them as a single erratic behavior.
| Cache | Where it lives | What it stores | Lifetime |
|---|---|---|---|
| Request Memoization | Server, in memory | Result of a repeated fetch | A single render |
| Data Cache | Server, persistent | Result of fetch across requests | Until revalidated |
| Full Route Cache | Server, build | HTML + RSC payload of static routes | Until redeploy/revalidate |
| Router Cache | Client, in memory | Already-visited route segments | Session / staleTime |
1. Request Memoization
The most harmless one, and the only one that isn’t really Next.js’s but React’s. If you call the same fetch (same URL, same options) several times within a single render —say the layout and the page both request the same user— React runs it once and shares the result.
Its scope is a single render pass: it’s created when the render starts and thrown away when it ends. It doesn’t persist across requests. It’s deduplication, not caching in the “stale data” sense, which is why it almost never bites you.
2. Data Cache
This is where it gets serious. The Data Cache truly persists: it stores the result of your fetch calls on the server, across distinct requests and even across deployments. It’s the piece ISR (Incremental Static Regeneration) was built on.
And here was the default that caused half the pain: in v13 and v14, fetch was cached by default (cache: 'force-cache'). You’d request data from your API, Next stored it indefinitely, and your page showed last hour’s version without you asking for anything. You controlled it with options on fetch itself:
// v13/v14: cached by default, controlled with options
await fetch(url) // cached forever
await fetch(url, { cache: 'no-store' }) // never cached
await fetch(url, { next: { revalidate: 3600 } }) // ISR: revalidate hourly
await fetch(url, { next: { tags: ['products'] } })// invalidatable by tag
The problem wasn’t technical, it was ergonomic: the default behavior was invisible and sticky. And it only applied to fetch; if you fetched data with the Prisma client or an SDK, you didn’t enter the Data Cache and had to cache by hand with unstable_cache.
3. Full Route Cache
One level up. When a route is static, Next prerenders it at build and stores the full result: the HTML and the RSC payload. In production that route is served from the cache without running the render again. It’s what makes a landing page fly.
The Full Route Cache is tied to the Data Cache: a static route is considered fresh as long as the data it depends on is. It’s invalidated on redeploy or when you revalidate the underlying data. The consequence that confused everyone: a page could get “frozen” at build because, unbeknownst to you, all its data was cacheable and therefore the entire route was static.
4. Router Cache
The only one that lives in the browser. As you navigate between pages (soft navigation), Next keeps the RSC payload of already-visited segments in memory. If you go back, the view is restored instantly without touching the server.
Sounds good, and it is, until it bites: in v14, this cache could serve you a stale version of a dynamic page for seconds after the data changed, because its freshness wasn’t controlled by the data but by a client-side staleness window. “I did the mutation, navigated, and I still see the old thing” was almost always this.
why it hurt
Stack the four layers and you get the App Router’s cultural problem: too much implicit magic. Caching was the default behavior in three of the four layers, the decisions were invisible, and debugging “why is this stale” forced you to hold a mental model of all four at once. On top of that, two of them (cached fetch, cached route handlers) cached things most people expected to be fresh.
The Next team acknowledged it themselves. The direction was clear: less magic, more predictable defaults, and making caching explicit.
v15: the turn toward explicit
Next.js 15 (October 2024) was, above all, a correction of defaults. It didn’t rewrite the four layers; it stripped them of their invisible stickiness:
fetchis no longer cached by default. It moves tono-store. If you want caching, you ask for it explicitly. This just inverts the most confusing default.- GET Route Handlers are no longer cached by default. Before, a
route.tswith GET was static without warning; now it’s dynamic unless you opt intoforce-static. - The Router Cache stops caching dynamic pages (default staleTime of
0for page segments). Goodbye to “I navigated and see the old thing.” - Request APIs become async:
cookies(),headers(),paramsandsearchParamsare nowawait. It’s the hint the framework needs to know, unambiguously, that you’re touching something dynamic.
// v15+: cookies/headers/params/searchParams are async
import { cookies } from 'next/headers'
export default async function Page() {
const session = (await cookies()).get('session')
// ...
}
The philosophy shifted from “we cache for you and you opt out” to “you decide when to cache.” But it was still four layers with four distinct control mechanisms. The next step was to unify them.
v16: Cache Components
Next.js 16 (October 21, 2025) is where the model genuinely reorders. The centerpiece is called Cache Components and you enable it with a flag:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
The mental shift is total. In the classic model, caching was the default and you opted out. In Cache Components it’s the reverse: everything is dynamic by default and caching is opt-in. You don’t cache a whole route or configure layers: you explicitly mark which slice of the UI is cacheable, with a directive.
And enabling it makes PPR the default behavior, retiring the old experimental flags experimental.ppr and experimental.dynamicIO (Cache Components was, in fact, the new name for dynamicIO). PPR stands for Partial Prerendering: pre-building only part of the page instead of all-or-nothing.
the new default: one part fixed, one part that arrives later
Think of a product page. The header, the description, the photos: they’re the same for everyone and don’t change minute to minute. But the “recommended for you” block depends on who you are. Before, you had to pick one of two extremes: either the whole page was static (fast, but that personal block went stale) or the whole page was dynamic (always fresh, but slow for everyone).
Partial prerendering breaks that “all or nothing.” It serves the part that’s the same for everyone instantly —it comes pre-built— and leaves holes for the personalized pieces, which fill in a second later without making the user stare at a blank page. One response: the fixed part first, the personal part as soon as it’s ready.
And how does Next know what goes where? With <Suspense>. Anything you wrap in <Suspense> is a hole that fills in later; everything else is the fixed part. That’s why, with cacheComponents on, any code that reads user data (cookies(), headers(), an uncached query) has to live inside a <Suspense> — if it doesn’t, Next stops your build and tells you. That <Suspense> is, literally, the line between what’s fixed and what arrives later.
import { Suspense } from 'react'
export default function ProductPage() {
return (
<>
<ProductDetails /> {/* static, goes in the shell */}
<Suspense fallback={<Skeleton />}>
<Recommendations /> {/* dynamic, arrives via streaming */}
</Suspense>
</>
)
}
actually caching: use cache
For shared data that tolerates being “a little stale” —the direct replacement for ISR— you mark the function or component with 'use cache', control freshness with cacheLife and invalidation with cacheTag:
import { cacheLife, cacheTag } from 'next/cache'
async function getProducts() {
'use cache'
cacheLife('hours') // freshness profile
cacheTag('products') // tag for on-demand invalidation
return db.query('SELECT * FROM products')
}
Notice what changed from the old model: you no longer cache at the fetch or route level, and it no longer matters whether the data comes from fetch, from Prisma, or from anywhere. You cache a function’s return value. It’s the same mechanism for everything.
cacheLife manages three windows —stale (how long the client can use cached data without checking), revalidate (after this, the next request triggers a background refresh) and expire (after this with no requests, the next one waits for fresh content)— and accepts named profiles ('hours', 'days', 'max') or a custom one:
cacheLife({ stale: 30, revalidate: 120, expire: 600 }) // seconds
invalidating: revalidateTag and updateTag
After a mutation, you invalidate by tag. And here are two changes that will bite you if you’re coming from v15:
'use server'
import { revalidateTag, updateTag } from 'next/cache'
export async function updateProduct(id: string, data: ProductData) {
await db.products.update({ where: { id }, data })
revalidateTag('products', 'max') // ⚠️ now requires a 2nd argument
}
revalidateTagnow requires a second argument: acacheLifeprofile that enables stale-while-revalidate behavior. The single-argument signature is deprecated; the recommended value is'max'.updateTagis new and Server-Actions-only. It gives read-your-writes semantics: it invalidates synchronously within the same request, so the user who just made a change sees it immediately instead of the stale version.revalidateTagis eventual;updateTagis right now.
how the old caches map to the new model
If you already had the four-layer model in your head, this table is the bridge. The layers didn’t disappear; they became explicit or hid themselves well:
| Classic model | In Cache Components (v16) |
|---|---|
| Request Memoization | Unchanged (it’s React’s); you don’t touch it |
Data Cache + unstable_cache | 'use cache' + cacheLife + cacheTag |
ISR (next: { revalidate }) | cacheLife() with its profile |
| Full Route Cache | PPR’s static shell (prerendered) |
| Tag-based invalidation | revalidateTag(tag, 'max') / updateTag(tag) |
| Router Cache (client) | Still there, no longer caching dynamics by default |
The big change isn’t that there are new APIs: it’s that the control point moved. Before, you cached at the edges (the fetch, the route, scattered options); now you cache at the center (the function that produces the data), with a single directive and a single invalidation model.
the decision tree
When you’re unsure what to do with a piece of data, follow this chain —it’s the same reasoning the Vercel team recommends:
- Is the data specific to this user? (session, cart, dashboard) → dynamic, inside
<Suspense>. Don’t cache it. - If not, can it tolerate being a little stale? →
'use cache'. If it can’t (it has to be exact to the second), leave it dynamic. - How do you revalidate it? On a schedule →
cacheLifewith its profile. After mutations →cacheTag+revalidateTag(orupdateTagif the user must see their own change instantly).
| Case | What to use |
|---|---|
| Landing, blog, docs | Static (default), nothing to mark |
| Catalog, shared prices | 'use cache' + cacheLife + cacheTag |
| Dashboard, logged-in user data | Dynamic + <Suspense> |
| Mostly-static page with personal pieces | PPR (shell + dynamic holes) |
| Mutation the author must see now | updateTag |
so, what do I do today?
If you’re starting a new project on v16: turn on cacheComponents: true and adopt the “dynamic by default, caching opt-in” mental model. It’s simpler to reason about because it kills the magic: nothing is cached until you say so, and when you say so, it’s visible in the code.
If you maintain a v14/v15 project: you don’t have to migrate tomorrow. The classic model —route segment config (export const dynamic, export const revalidate), generateStaticParams, fetch options— is still valid as long as you don’t flip the flag. But be clear that it’s the model on its way out: under Cache Components, route segment config for revalidation is deprecated in favor of use cache.
If you’re going to migrate: Next.js 16 requires Node 20.9+ and TypeScript 5.1+, and ships an official codemod that automates much of the mechanical work (renaming middleware to proxy, dropping the unstable_ prefixes, etc.).
And if I had to sum up the whole saga in one sentence: Next.js caching went from “trust us to cache well for you” to “you say what to cache, and it shows.” Three years of releases to learn that, in something as loaded with trade-offs as data freshness, explicit beats magic almost every time.