Next.js 16 reframed caching around a single idea: nothing is cached unless you say so, and when you do cache, you say so explicitly and locally. Cache Components replaces the implicit, often-surprising caching of earlier versions with a model built from a few composable primitives — use cache, cacheLife, cacheTag, revalidateTag, and updateTag. This guide gives you a mental model that holds up under real traffic, with the trade-offs we have hit shipping production apps for clients.
The shift: explicit caching by default
For years the hardest part of Next.js caching was knowing what was cached. The App Router cached fetches, route segments, and the full route output with defaults that changed across minor versions. Teams learned to debug by deleting .next and praying.
Cache Components inverts that. The default is dynamic. A page renders on every request unless something in it opts into caching. You opt in with the use cache directive, which marks a function, file, or component as cacheable. There is no hidden fetch cache to fight, no force-dynamic exorcism. If a value is expensive and reusable, you wrap it; if it is per-request, you leave it alone.
The mental model in one sentence: the request is dynamic, and `use cache` carves out islands of static, reusable output inside it.
use cache: the unit of caching
use cache can sit at three scopes, and the scope determines what gets memoized.
- At the top of a file, every exported function in that file becomes cacheable.
- At the top of a function, that function's return value is cached, keyed by its arguments.
- At the top of a component, the rendered output of that component (and its props as the key) is cached.
The cache key is derived from the inputs the function closes over: its serializable arguments and any cached data it reads. This is why use cache functions must take serializable arguments — the runtime hashes them to build the key.
// lib/catalog.ts
import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag } from 'next/cache'
export async function getCategory(slug: string) {
'use cache'
cacheLife('hours')
cacheTag(`category:${slug}`)
const rows = await db.query(
`select id, name, price from products where category = $1 order by name`,
[slug],
)
return rows
}This function runs the SQL once per slug, caches the result, refreshes it on the hours profile, and can be invalidated by the tag category:${slug}. Notice there is no per-request data anywhere — no cookies, no headers, no searchParams. That is the rule: a `use cache` boundary cannot read request-scoped data, because the whole point is that its output does not depend on the request.
cacheLife: how long, and how stale
cacheLife controls the freshness contract. It takes a named profile or an explicit object, and it encodes three numbers:
- stale — how long a client may serve the value without revalidating.
- revalidate — how long the server keeps the value fresh before refreshing it in the background.
- expire — the hard ceiling, after which the value must be regenerated before serving.
Built-in profiles range from seconds to max. Use the named profiles for intent and reach for the explicit form only when a profile does not fit:
cacheLife({ stale: 60, revalidate: 300, expire: 3600 })The trap worth naming: stale-while-revalidate semantics mean a user can see data up to stale + revalidate old before a background refresh lands. For a product catalog that is fine. For an inventory counter it is not. Match the profile to how wrong the data is allowed to be, not to how often it changes.
cacheTag and revalidateTag: targeted invalidation
cacheTag attaches one or more labels to a cached entry. revalidateTag purges every entry carrying that label. This is the backbone of on-demand invalidation and it is far better than time-based expiry for anything a user can edit.
Tag at the granularity you will invalidate at. If a single product changes, you want to bust that product and the categories it belongs to — not the entire catalog.
// app/admin/products/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function updateProduct(id: string, data: ProductInput) {
const product = await db.update('products', id, data)
revalidateTag(`product:${id}`)
revalidateTag(`category:${product.category}`)
}The discipline that pays off: design your tag taxonomy before you scatter cacheTag calls. We use a small convention — entity:id and collection:key — so any write knows exactly which tags to revalidate. A tag you cannot reconstruct from a mutation is a tag you will forget to bust.
updateTag: read-your-writes after a mutation
revalidateTag marks entries stale, so the *next* request regenerates them. That is correct for most cases, but it produces a visible lag: the user who just saved a change can still see the old value on the immediate navigation.
updateTag closes that gap. Called inside a Server Action, it refreshes the tagged caches and makes the new value available within the same response, giving you read-your-writes consistency for the acting user.
- Use `revalidateTag` for fan-out invalidation triggered by webhooks, cron, or background jobs where eventual consistency is fine.
- Use `updateTag` inside a Server Action when the same user must see their change immediately on the next render.
Reaching for updateTag everywhere defeats the purpose of caching, because it forces synchronous regeneration. Use it where the UX demands immediacy — settings saves, profile edits, "publish" buttons — and let revalidateTag handle the rest.
Static shell, dynamic islands
This is where the model becomes a layout strategy rather than a per-function decision. A route in Cache Components is composed of a static shell — the parts wrapped in use cache that prerender once — and dynamic islands — the parts that read request data and stream in per request.
The shell is your header, nav, footer, marketing copy, and cached data sections. The islands are anything personal: the cart badge, the greeting with the user's name, A/B variants, anything reading cookies or headers(). You separate them with <Suspense>. The shell ships instantly from cache; the islands resolve and stream into their suspense boundaries.
// app/products/[slug]/page.tsx
import { Suspense } from 'react'
import { getCategory } from '@/lib/catalog'
import { CartBadge } from './cart-badge' // reads cookies — dynamic
export default async function Page({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const products = await getCategory(slug) // cached shell data
return (
<main>
<Suspense fallback={<CartBadgeSkeleton />}>
<CartBadge /> {/* dynamic island */}
</Suspense>
<ProductGrid products={products} /> {/* static shell */}
</main>
)
}The shell gives you near-static performance and edge-cacheable HTML; the islands keep the page correct for each user. This is the pattern that makes "fast and personalized" stop being a contradiction.
When to cache, and when not to
A decision list we apply on every route:
- Cache it when the value is expensive to compute, shared across users, and tolerant of being slightly stale: catalogs, published content, navigation, pricing tables, rendered MDX, third-party data with generous TTLs.
- Cache with tight `cacheLife` + tags when it is shared but edited: blog posts, product details, anything an admin touches. Pair short profiles with on-demand
revalidateTag. - Do not cache anything request-scoped: authenticated dashboards, cart contents, anything reading cookies, headers, or
searchParams, anything with per-user pricing or permissions. - Do not cache writes or side effects.
use cacheis for pure reads; the runtime assumes the function is referentially transparent.
The failure mode we see most: teams wrap a component that reads cookies() and get a build error, then wrap it "one level up" to silence the error and ship a cache that leaks one user's data to another. If a boundary needs request data, it is a dynamic island. Move the cached read down to the pure data function and keep the request-reading part outside the cache.
A second, quieter failure: over-caching with max profiles and no tags, so content goes stale and nobody knows why. If you cannot name how a cached entry gets invalidated, do not cache it yet.
A practical adoption path
You do not have to convert a codebase in one pass. The approach that has worked for us:
- Enable Cache Components and let everything render dynamic. Confirm the app is correct first; performance second.
- Profile the slow routes. Find the expensive, shareable reads.
- Wrap those reads in
use cachewith conservativecacheLifeprofiles and a tag per entity. - Wire
revalidateTaginto the mutations that change that data, andupdateTaginto the handful of Server Actions that need instant feedback. - Pull personalized fragments behind
<Suspense>so the cached shell can prerender.
Caching last, after correctness, is the part teams skip and regret. The new model rewards it because the default is safe.
Frequently Asked Questions
What is the difference between use cache and the old fetch cache?
The old model cached fetch calls implicitly with shifting defaults, which made caching hard to reason about. use cache is explicit and opt-in: you mark a function, file, or component as cacheable, and the runtime keys it on its arguments. Nothing is cached unless you wrap it, so behavior is local and predictable.
Can a cached component read cookies or headers?
No. A use cache boundary cannot read request-scoped data like cookies(), headers(), or searchParams, because its output must not depend on the request. If a component needs that data, treat it as a dynamic island: render it outside the cache, behind a <Suspense> boundary, and keep only the pure data reads inside use cache.
When should I use updateTag instead of revalidateTag?
Use revalidateTag for fan-out invalidation from webhooks, cron, or background jobs where eventual consistency is acceptable. Use updateTag inside a Server Action when the user who made the change must see it immediately on the next render. updateTag refreshes within the same response, giving read-your-writes consistency at the cost of synchronous regeneration.
How do cacheLife profiles affect staleness?
cacheLife sets three values: stale, revalidate, and expire. With stale-while-revalidate semantics, a value can be served up to stale plus revalidate seconds old before a background refresh completes. Choose a profile based on how wrong the data is allowed to be, not how often it changes. Tight profiles plus tags beat long TTLs for editable content.
Does Cache Components work with streaming and Suspense?
Yes, and it is the intended pattern. The cached static shell prerenders and ships instantly, while dynamic islands wrapped in <Suspense> stream in per request. This separation is what lets a page be both edge-fast and personalized, instead of forcing the whole route to be either fully static or fully dynamic.
Working with CodeAustral
We build and ship production Next.js applications — caching strategy, performance, payments, and the unglamorous correctness work that keeps them stable under real traffic. If you are adopting Cache Components, untangling a caching regression, or planning a migration and want a second pair of expert hands, send us a short brief at https://codeaustral.com/contact and we will tell you how we would approach it.

