use cache and the migration traps
Migrate off unstable_cache and revalidate. Automatic keys and closure capture, frozen non-determinism, the new revalidateTag signature, and serverless caveats.
One directive replaces four APIs you know
'use cache' is not just a new way to cache. It is the replacement for unstable_cache, the fetch cache options, fetchCache, and dynamic = 'force-static'. This lesson is the migration, plus the four things that surprise people who cached the old way.
unstable_cache → 'use cache' · revalidate = N → cacheLife({ revalidate: N }) · force-static → 'use cache' + cacheLife('max'). You place the directive at a file, component, or function scope; the function level is the common one.
// Before: unstable_cache with manual keysconst getUser = unstable_cache( async (id) => db.users.find(id), ['user'], // keyParts — you maintained these by hand { tags: ['users'], revalidate: 60 },) // After: 'use cache' — keys are generated for youasync function getUser(id: string) { 'use cache' cacheTag('users') cacheLife({ revalidate: 60 }) return db.users.find(id)}Trap 1: keys are automatic, including the closure
No more keyParts. The key is built from the function id, the serializable arguments, and the captured closure variables. That last part is the trap: anything you close over is part of the key, whether you meant it or not.
function Report({ orgId }: { orgId: string }) { const big = buildHugePolicyObject() // captured in the closure below async function load(range: string) { 'use cache' // Cache key = orgId (closure) + big (closure) + range (argument). // 'big' is serialized into the key on every call. That is the trap. return db.metrics(orgId, range) } return load('30d')}Why is capturing `big` in the key a problem, and what breaks if `big` were a per-request value instead of a heavy constant?
Trap 2: request data cannot enter a cache
A cached result must be reusable, so cookies(), headers(), and searchParams throw inside 'use cache'. Read them outside and pass the value in; it becomes part of the key. The one escape hatch is 'use cache: private', which you will meet in Lesson 5, not the default.
A cookies() read buried deep in a cached call tree does not fail next build. It throws at request time under next start. Verify cached code on a running server, not just a green compile.
Trap 3: non-determinism freezes at fill time
Math.random(), Date.now(), new Date(), and crypto.randomUUID() inside a cache execute once when the entry fills, then serve that frozen value to everyone. If you want per-request values, use connection() to defer back to request time, outside the cache.
import { connection } from 'next/server' async function getBanner() { 'use cache' // Runs ONCE when the cache fills, then frozen. Every user sees the same id. const id = crypto.randomUUID() return db.banner(id)} async function RequestId() { await connection() // opt back into request time return <span>{crypto.randomUUID()}</span> // fresh per request}Trap 4: revalidateTag changed, and updateTag is new
Two changes from the invalidation you know. revalidateTag now takes a cacheLife profile as a second argument to enable stale-while-revalidate. And updateTag is new: it gives read-your-writes in the same request, which revalidateTag never guaranteed.
'use server'import { revalidateTag, updateTag } from 'next/cache' export async function publishPost(data: FormData) { await db.posts.create({ data }) // Next.js 16: revalidateTag now takes a cacheLife profile as a 2nd arg. revalidateTag('posts', 'max') // background, stale-while-revalidate} export async function editPost(id: string, data: FormData) { await db.posts.update({ where: { id }, data }) updateTag(`post-${id}`) // immediate, same request reads its own write}| Call | Timing | Reach for it when |
|---|---|---|
revalidateTag(tag, profile) | Background | A later request may see fresh data. The common choice. |
updateTag(tag) | Immediate | The same response must reflect the write (Server Actions only). |
refresh() | Immediate | You need to refresh uncached data only, without touching the cache. |
Plain 'use cache' is in-memory and does not persist across serverless instances or deploys. For a shared, durable cache use 'use cache: remote' (platform KV/Redis).
A dashboard number is cheap to compute, changes a few times a day, and every user should see the same value. It is currently a `fetch(url, { next: { revalidate: 3600 } })`. What is the clean migration?
What you learned
'use cache'replacesunstable_cache, fetch options, andforce-static.- Keys are automatic from arguments and the closure; keep request data and heavy captures out.
- Non-deterministic calls freeze at fill time; use
connection()for per-request values. revalidateTagtakes a profile arg now;updateTagadds read-your-writes.- In-memory cache is per instance; use
'use cache: remote'to share it.
Next: navigation. Your existing loading.tsx and top-level boundaries are about to become the thing that blocks the shell.