use cache marks an async function, component or whole module as cacheable, and Next.js stores what it returns keyed by the arguments it received. It needs cacheComponents: true in next.config.ts, and it needs Next.js 16 or later. Nearly everything surprising about the directive follows from that one sentence about keys.
The flag itself is small:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
What it switches on is not small. Data fetching becomes dynamic by default, Partial Prerendering becomes the App Router’s normal behaviour (experimental.ppr and the experimental_ppr segment config are removed), and route segment configs like revalidate and fetchCache start failing the build. Everything below is checked against the Next.js docs at version 16.3.2, as of August 2026.
What the Next.js use cache directive actually caches
Three placements, one hard rule: the function must be async.
// Function level
export async function getData() {
'use cache'
const res = await fetch('https://api.example.com/data')
return res.json()
}
// Component level
export async function MyComponent() {
'use cache'
return <></>
}
Put the directive at the top of a file instead and every export it covers is cached, framework exports included, so generateMetadata and generateStaticParams in such a file have to be async too. A page.tsx or layout.tsx is a module like any other, so a file-level directive there caches that segment. Segments cache independently. Prerendering a whole route means adding the directive to the page, the layout, and any parallel route slots.
Arguments and return values must be serializable, and they go through two different systems. Arguments use React Server Component serialization; return values use the looser Client Component serialization. So you can return JSX but you cannot accept it as an argument, and class instances, functions, symbols and URL instances are rejected outright.
There is one escape hatch, and it is the reason composition still works. A value you pass through without reading does not affect the entry:
async function CachedWrapper({ children }: { children: ReactNode }) {
'use cache'
// Never read or modify children, just place it
return (
<div className="wrapper">
<header>Cached Header</header>
{children}
</div>
)
}
A <DynamicComponent /> handed in as children renders at request time inside a cached shell. Server Actions pass through the same way, as long as you never call them inside the cached function.
How is the cache key built?
Four inputs: the build ID (or deploymentId, if you set one), a secure hash of the function’s location and signature, the serializable arguments, and in development an HMR refresh hash.
Closures are the part that catches people out. Variables captured from an outer scope get bound as arguments and join the key:
async function Component({ userId }: { userId: string }) {
const getData = async (filter: string) => {
'use cache'
// Key includes userId from the closure and filter from the argument
const res = await fetch(
`https://api.example.com/users/${userId}/data?filter=${filter}`
)
return res.json()
}
return getData('active')
}
Every user and filter combination gets its own entry. Correct behaviour, and also how you build a cache with a million entries without meaning to.
The build ID in the key has a second consequence. Nothing survives a deploy, including use cache: remote. If a value genuinely has to outlive a deployment, the fetch Data Cache and unstable_cache still do that. use cache does not.
Which cacheLife profile should I use?
cacheLife sets three numbers. stale is how long the client router reuses content without asking the server. revalidate is when the server refreshes in the background. expire is the point past which the next request waits for fresh content instead of being handed something old.
The presets, from the cacheLife reference:
profile stale revalidate expire
default 5 minutes 15 minutes never
seconds 30 seconds 1 second 1 minute
minutes 5 minutes 1 minute 1 hour
hours 5 minutes 1 hour 1 day
days 5 minutes 1 day 1 week
weeks 5 minutes 1 week 30 days
max 5 minutes 30 days 1 year
Call it in every cached scope. The reason is nesting. With an explicit cacheLife on the outer cache, the outer lifetime wins no matter what sits inside it. Without one, an inner cache with a shorter life drags the outer cache down to its lifetime, while an inner cache with a longer life cannot push it past the 15 minute default. That is a lot of behaviour to infer from a component somebody else wrote.
Short lifetimes also decide where content is served from. A revalidate of 0 or an expire under five minutes drops the scope out of the prerender, leaving a hole filled at request time. A stale under 30 seconds does the same, because a prefetch would expire before anyone could click the link. Of the presets, only seconds crosses either threshold.
Nest a short-lived cache inside one with no explicit cacheLife and the build fails rather than quietly propagating the short life outwards. Pick a side to fix it: cacheLife('default') on the outer scope keeps it prerendered, or a short profile plus a <Suspense> boundary makes the dynamic hole deliberate.
Why does cookies() throw inside use cache?
Because a cached scope has no request behind it. cookies(), headers() and searchParams fail with the next-request-in-use-cache error, and the restriction follows the call stack, so a helper three levels down that reads a header fails identically.
Read them outside, pass values in:
import { cookies } from 'next/headers'
import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<p>Loading...</p>}>
<Dashboard />
</Suspense>
)
}
async function Dashboard() {
const theme = (await cookies()).get('theme')?.value
return <ThemedPanel theme={theme} />
}
The nastier variant does not throw. Hand a cached function an unawaited promise of runtime data and the build hangs, then dies after 50 seconds with Error: Filling a cache during prerender timed out, likely because request-specific arguments such as params, searchParams, cookies() or uncached data were used inside "use cache". Await first, then pass the value.
On a dynamically rendered route, the direct version only surfaces when the route runs. It can pass next build and fall over under next start. Check that before you treat a green build as a green deploy.
What replaces revalidate, fetchCache and unstable_cache?
export const revalidate = 3600 becomes cacheLife('hours'). fetchCache, unstable_noStore and dynamic = 'force-dynamic' are all redundant now that nothing caches by default. dynamicParams is not supported and fails the build with Route segment config "dynamicParams" is not compatible with nextConfig.cacheComponents. runtime = 'edge' is out entirely, because Cache Components requires the Node.js runtime.
unstable_cache maps across cleanly and sheds its key-parts array, since the arguments are the key:
import { cacheLife, cacheTag } from 'next/cache'
import { db } from '@/lib/db'
export async function getUser(id: string) {
'use cache'
cacheLife('hours')
cacheTag('users')
return db.query.users.findFirst({ where: eq(users.id, id) })
}
generateStaticParams returning [] now errors with empty-generate-static-params. It has to return at least one param so Next.js can prerender the route and check the shell is not empty. Paths you leave out are still served, streamed at request time.
For a large app, the incremental route is export const instant = false on the segments that are not ready, applied across the tree in one pass:
npx @next/codemod@canary cache-components-instant-false ./app
Then remove the opt-out one route at a time. It will not clear synchronous IO errors: new Date(), Date.now(), Math.random() and crypto.randomUUID() during prerender fail whether a segment opts out or not. Vercel also ships a next-cache-components-adoption skill that drives the same migration through a coding agent, which is how we have been approaching it in our Next.js development work.
cacheTag, updateTag or revalidateTag?
Tag inside the cached scope, invalidate outside it.
'use server'
import { updateTag } from 'next/cache'
export async function createPost(formData: FormData) {
const post = await db.post.create({ data: { /* ... */ } })
updateTag('posts')
updateTag(`post-${post.id}`)
}
updateTag works only in a Server Action. It expires the tag immediately, so the next request waits for fresh data, which is what you want after a user’s own mutation. revalidateTag works in Server Actions and Route Handlers and now takes a cache profile as its second argument, so revalidateTag('posts', 'max') gives you stale-while-revalidate. Drop the profile and you get the old immediate-expiry behaviour. Tags cap at 256 characters.
Does the in-memory cache work on serverless?
Frequently not, and this is the thing most likely to make use cache look broken in production.
The default handler is an in-memory LRU. Self-hosted, entries persist across requests and you size the store with cacheMaxMemorySize. On serverless, consecutive requests can land on different instances, so entries often are not reused at all. Build-time caching still works, which is exactly why the same code looks fine locally and does nothing under real traffic.
use cache: remote moves storage to a platform-provided handler such as Redis or a KV store. It costs a network round trip per lookup and usually costs money. Reach for it when the upstream data source cannot absorb your traffic, not as a default. Where you deploy changes that calculation more than people expect, which is the same reason the Cloudflare Pages and Workers comparison is worth reading before you pick a host.
Turn cacheComponents on for any new Next.js 16 app and write an explicit cacheLife in every cached scope from day one. For an existing app, run the codemod, get the whole thing building with instant = false, then convert the routes that actually gain something from a static shell. Leave it off if you are pinned to runtime = 'edge', if your caching depends on values surviving deploys through the fetch Data Cache, or if you are still on Next.js 15 and the upgrade is not scheduled.