Before, one line at the top of a route: export const revalidate = 60. After, one line inside the function that owns the data: cacheLife('minutes').
The obvious reading is a rename. It is not. ISR gave a route with five data sources exactly one freshness contract. Product prices want 60 seconds, the nav menu wants a week, markdown wants forever, and the page has one dial. So we split routes artificially, or we over-revalidated everything, and on-demand revalidatePath was a hammer shaped like a URL. The route was never the natural boundary of freshness. It was the boundary Next could see.
Next 9.5 shipped Incremental Static Regeneration in 2020, and the beta hid in 9.4 as unstable_revalidate. It carried half the framework's marketing and taught one model: the route is the cache unit. Next 16's Cache Components end that era.
The new unit
Flip the switch. Every route becomes dynamic by default, with zero implicit caching:
// next.config.ts
const nextConfig = { cacheComponents: true }Then 'use cache' moves the freshness contract to the code that owns the data:
'use cache'
import { cacheLife, cacheTag } from 'next/cache'
export async function fetchPapers() {
cacheLife('max')
cacheTag('papers')
// fs reads, db calls, whatever
}That is real code from this site. Papers are markdown on disk, immutable per deploy, so max. A pricing function next door declares cacheLife('minutes'). A personalized fragment declares nothing and stays dynamic. Each function signs its own freshness contract, and a route becomes what it composes.
Three dials where ISR had one
cacheLife is not a fancier revalidate. It is three numbers, per the profile table:
stale: how long the client router serves its in-memory copy without asking the server. ISR never had this dial. The client cache was folklore you debugged.revalidate: server-side background refresh. The docs describe it as ISR's behavior: serve stale, regenerate in the background.expire: the hard ceiling. Past it, the next request blocks for fresh data instead of serving stale. ISR's closest equivalent: delete the page by hand.
The named profiles fill the triple. seconds is 30s/1s/1m, hours is 5m/1h/1d, max is 5m/30d/1y. Note that max still background-revalidates monthly, so the ceiling is a year and "cache forever" is not in the vocabulary. You define your own profiles too, so a marketing site gets a campaign profile that dies on Friday:
const nextConfig = {
cacheComponents: true,
cacheLife: {
campaign: { stale: 300, revalidate: 3600, expire: 604800 },
},
}The key includes the closure
Key derivation is the reason this is a directive and not a cache(fn, keys) wrapper. Vercel's composable caching post spells the key out. It is the build ID, a hash of the function's location and signature, and the serialized arguments and every closed-over value. The compiler walks the closure, so a userId read from parent scope joins the key with zero annotation:
async function ProfilePanel({ userId }: { userId: string }) {
async function loadProfile() {
'use cache'
// userId is closed over: it joins the key automatically.
// Forgetting it is impossible; the compiler saw it.
return db.profile.find(userId)
}
return <Panel data={await loadProfile()} />
}A runtime wrapper cannot see closures. Hand-maintained key arrays drift from the code they describe. Both bug classes just died.
Serialization is React Flight, not JSON, and that buys the elegant part. Non-serializable values like children pass through as references. They stay out of the key, and React restores them at render. A cached layout wraps dynamic children without busting its own entry:
async function CachedShell({ children }: { children: React.ReactNode }) {
'use cache'
const nav = await fetchNav() // cached with the shell
return <Frame nav={nav}>{children}</Frame> // children stay dynamic
}It is the 'use client' donut pattern, reapplied to caching. Same donut, different filling.
Invalidation grew four verbs
Tags name data, not URLs, and Next 16 split invalidation by who is asking:
'use server'
import { updateTag } from 'next/cache'
export async function publishPaper(draft: Draft) {
await persist(draft)
updateTag('papers') // expires now: the redirect sees the new paper
}updateTagis server-actions-only and gives read-your-writes. The mutation's own response renders fresh data, so the CMS trick of bumpingrevalidateto 1 retires here.revalidateTag(tag, 'max')is the stale-while-revalidate cousin for route handlers and webhooks. It marks entries stale and regenerates lazily, so the next visitor may still catch the old page.revalidateTag(tag, { expire: 0 })is the webhook nuke.refresh()re-fetches uncached dynamic data without touching the cache, the server sibling ofrouter.refresh().
The single-argument form of revalidateTag is deprecated in Next 16. Four verbs where ISR had res.revalidate(path). That old call and revalidatePath both migrate the same way: pick a tag, then revalidateTag(tag, profile).
Tags are the API now, so tag design matters. Tag by entity for surgical writes (paper:${slug}) and by collection for list pages (papers), and let a publish call both. Then leave alone what a deploy already invalidates. The build ID sits in the key, so every deploy is a global flush for free. This site's markdown needs no tag gymnastics at all.
The part that will bite someone
Sharp edges, all by design.
cookies()andheaders()inside a'use cache'scope throw immediately. An entry serving multiple users cannot depend on one user's cookies.- Await uncached IO outside a
<Suspense>boundary and the build fails with the blocking-route error. That is the whole model in one sentence: everything in a route is either cached (part of the prerendered shell) or explicitly given a fallback. Math.random()orDate.now()inside a cached function is not an error. It is frozen. Every visitor gets the same "random" hero image until revalidation.
Entropy is input, and inputs belong outside the boundary.
One operational honesty note. On serverless, the durable win is the prerendered shell.
Three flavors, one directive.
The obituary
ISR deserves a kind one. It made static sites dynamic enough, five years before the real primitive was ready. It survives as the degenerate case: 'use cache' at the top of layout.tsx and page.tsx, one entry per segment, one dial in use. That is also the whole migration for a getStaticProps-era page. But route-level caching was a compromise with the bundler. Freshness is a property of data, and the API finally says so. Stop thinking in pages. Think in functions with expiration dates.