Next.js Cache Components and the use cache Directive

Photo by Egor Litvinov on Unsplash
The use cache directive marks a route, component, or async function as cacheable in Next.js. It is unlocked by the cacheComponents flag and can sit at the file, component, or function level. Next.js builds a cache key from the build ID, a hash of the function's location and signature, and its serializable arguments.
Older Next.js cached data implicitly through framework defaults, fetch options, and route segment config. Cache Components inverts this: data is dynamic by default and you opt into caching explicitly with use cache. The caching decision now lives in the code rather than being spread across defaults.
cacheLife profiles set three timings on a cached scope: stale (how long the client serves it without checking the server), revalidate (how often the server rebuilds it in the background), and expire (the hard ceiling). Next.js ships presets like seconds, minutes, hours, days, weeks, and max, and you can define custom or inline profiles.
cacheTag attaches one or more string labels to a cache entry, accepting up to 128 tags of 256 characters each. From a Server Action or Route Handler you call revalidateTag with a label to purge every entry carrying it, serving stale content while it rebuilds. For read-your-own-writes, updateTag makes the change visible on the very next read.
Enabling cacheComponents makes Partial Prerendering the default, so the old experimental ppr flag and per-route config are removed. Next.js prerenders a static HTML shell from cacheable content, serves it instantly, and streams dynamic holes in as they resolve. use cache decides what enters the shell, while a Suspense boundary marks where dynamic content streams.

Photo by Egor Litvinov on Unsplash
Key Takeaway
Cache Components is the Next.js 16 model where data is dynamic by default and you opt into caching with the use cache directive. cacheLife sets how long an entry stays fresh, cacheTag labels it, and revalidateTag purges it on demand. It also makes Partial Prerendering the default rendering behavior.
For years I could never answer a simple question with confidence: is this page cached? In the App Router, a fetch might be cached by default, a route segment might be static, and a stray dynamic API could quietly turn the whole thing dynamic. The rules lived in framework defaults, not in my code, so the honest answer was usually a shrug followed by a production check.
Cache Components, the model that ships enabled through a single flag in Next.js 16, fixes that by inverting the default. Nothing is cached unless I say so. I reach for one directive, use cache, to mark exactly the route, component, or function whose output I want stored, and everything else stays dynamic and streams at request time. This post walks through the whole surface: the directive, cacheLife profiles, cacheTag and revalidateTag invalidation, the config flag, and how it all folds into Partial Prerendering.
The old caching story was implicit. Fetch requests were cached unless you passed a no-store option, full routes were prerendered unless something forced them dynamic, and helpers like unstable_cache bolted on extra layers. The result was powerful but hard to reason about, because the decision about what got cached was spread across defaults, fetch options, and route segment config rather than sitting plainly in the component.
Cache Components makes the decision explicit and local. Data fetching is dynamic by default, and you choose what to cache at the page, component, or function level. When you forget to cache something that is expensive, you still get correct, fresh output rather than a silently stale page. The trade you accept is that you now write the caching down on purpose, which is exactly the point. Here is how the two models compare.
| Aspect | Implicit caching (older defaults) | Explicit use cache |
|---|---|---|
| Default behavior | Data cached unless you opt out | Data dynamic unless you opt in |
| Opt-in mechanism | force-cache, route config, unstable_cache | One use cache directive in scope |
| Lifetime control | revalidate export and per-fetch options | cacheLife profiles, named or inline |
| Invalidation | revalidatePath and revalidateTag | cacheTag plus revalidateTag or updateTag |
| Where the decision lives | Framework defaults and config files | Visible in the cached scope itself |
Everything starts with one flag. Enabling cacheComponents in your Next.js config unlocks the use cache directive, the cacheLife function, and cacheTag. Under the hood this single flag replaced three separate experimental options that used to be configured on their own, unifying dynamic rendering, caching, and prerendering into one coherent setting introduced in Next.js 16.
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfigOnce the flag is on, use cache can sit at three levels. At the top of a file it caches every export, and those exports must all be async functions. At the top of a component it caches that component's rendered output. At the top of any async function it caches that function's return value. The cache key is built from the build ID, a hash of the function's location and signature, and its serializable arguments, so different inputs get different entries automatically.
The directive on its own uses a sensible default: content stays fresh on the client for five minutes and is revalidated on the server every fifteen. Most of the time I want to be deliberate, so I pair use cache with cacheLife to set the lifetime and cacheTag to label the entry. Here a product list is cached with the hours profile and tagged so I can purge it later.
// app/products/page.tsx
import { cacheLife, cacheTag } from 'next/cache'
async function getProducts() {
'use cache'
cacheLife('hours') // stale 5m, revalidate 1h, expire 1d
cacheTag('products') // label this entry for on-demand purges
const res = await fetch('https://api.example.com/products')
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts()
return <ProductGrid products={products} />
}Caching is only half the job; the other half is invalidation. When inventory changes, a Server Action calls revalidateTag with the same label. That marks the tagged entry stale and rebuilds it in the background, so readers keep getting instant responses while the fresh copy is generated. If I needed the very next read to see the change immediately, I would reach for updateTag instead, which is built for read-your-own-writes.
// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function refreshProducts() {
await updateInventory()
// Serve stale, then rebuild the 'products' entry in the background
revalidateTag('products')
}Always set an explicit cacheLife on a cached scope. Without one it inherits the default fifteen-minute revalidate, and a nested short-lived cache can silently shorten the whole tree. Naming a profile lets anyone read the scope and know its freshness at a glance, without tracing through nested caches.
Every cache lifetime is three numbers. Stale is how long the client router serves the entry without checking the server. Revalidate is how often the server regenerates it in the background, much like Incremental Static Regeneration. Expire is the hard ceiling: after that long without traffic, the next request waits for a fresh copy. Next.js ships preset profiles so you rarely touch the raw numbers.
You can also define custom named profiles in the config, or pass an inline object for a one-off. Any property you omit inherits from the default profile. One caveat worth knowing: profiles with a revalidate of zero or an expire under five minutes, including the seconds profile, are treated as dynamic holes and excluded from the prerendered shell rather than baked in.
Time-based expiry is fine for content on a schedule, but most invalidation is event-driven: a product was edited, a post was published, a comment landed. cacheTag attaches one or more string labels to a cache entry, and you can generate those labels from the data itself, for example tagging an entry with a record id. A single call accepts up to 128 tags, each up to 256 characters.
From a Server Action or Route Handler you then call revalidateTag with a label to purge every entry carrying it. revalidateTag is the stale-while-revalidate option: readers keep the old copy until the rebuild finishes. updateTag is the stricter sibling for when the same user who made the change must see it on the very next read. Both give you surgical control instead of blowing away the whole cache.
Cached scopes run in isolation and cannot call cookies, headers, or read searchParams directly; read those outside the scope and pass the values in as arguments. And any uncached, dynamic data that is not wrapped in a Suspense boundary throws an error at build time, so you are forced to bound your dynamic parts on purpose.
Cache Components is not just about data; it is also the rendering model. Enabling it makes Partial Prerendering the default, so the old experimental ppr flag and per-route ppr config are gone. Next.js prerenders a static HTML shell from everything that is cacheable, serves that shell instantly, and streams the dynamic holes in as they resolve, all inside a single route.
That is why the directive and the Suspense boundary work as a pair. use cache decides what lands in the static shell; a Suspense boundary marks where a dynamic hole streams in with a fallback. You stop choosing between a fully static page and a fully dynamic one and instead compose both in the same tree. For me the real win is legibility: the cache boundary now lives in the code, so I can finally answer what is cached by reading the file.