fig. Bnextjs
Next.js 16 Cache Components: A Practical Guide to "use cache"
Next.js 16 makes caching opt-in. Learn how Cache Components, the use cache directive, cacheLife and cacheTag work, with copy-paste examples.
- Written by
- Mayank Panchal
- Published
- Reading time
- 4 min
- Filed under
- nextjs
- nextjs
- caching
- react
On this page · 7
For years, caching in the Next.js App Router worked by default and you opted out. Next.js 16 flips that. With Cache Components, nothing is cached unless you say so, and the way you say so is a single directive: "use cache".
This post covers what changed, how the directive behaves, and the patterns that cover most real pages.
Turning it on#
Cache Components sit behind one flag in next.config:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfigWith the flag on, a route is a static shell (everything that can be prerendered) plus dynamic holes that stream in at request time. This is the idea behind Partial Prerendering, now expressed through ordinary components instead of route-level switches.
The directive#
Put "use cache" at the top of an async function, an async component, or a whole file:
import { cacheLife } from 'next/cache'
export async function getUsers() {
'use cache'
cacheLife('hours')
return db.query('SELECT * FROM users')
}Three rules to remember:
- It must be async. Cached functions and components return promises.
- Arguments are the cache key. Along with the build ID and the function's identity, the serialisable arguments (or props) form the key. Different
userId, different entry. - Closures count too. A variable captured from an outer scope is bound as an argument and becomes part of the key.
That second rule is why you never write cache keys by hand.
Choosing how long: cacheLife#
cacheLife takes a preset profile name or an object:
cacheLife('days') // a preset
cacheLife({ revalidate: 900 }) // refresh in the background after 15 minutesUnder the hood a profile has three numbers:
| Setting | Meaning |
|---|---|
stale |
How long the client may reuse its copy without asking the server |
revalidate |
After this, the next request triggers a background refresh |
expire |
After this with no traffic, the next request waits for fresh data |
Set cacheLife explicitly in every use cache scope. Leaving it out silently applies the default profile, which is hard to spot when you read the code later.
Invalidating on demand: cacheTag#
Time-based expiry is not enough when content changes because someone edited it. Tag the entry, then invalidate the tag after a mutation:
import { cacheLife, cacheTag } from 'next/cache'
export async function getPost(slug: string) {
'use cache'
cacheLife('max')
cacheTag(`post:${slug}`)
return db.post.findUnique({ where: { slug } })
}'use server'
import { updateTag } from 'next/cache'
export async function savePost(slug: string, data: PostInput) {
await db.post.update({ where: { slug }, data })
updateTag(`post:${slug}`)
}A long cacheLife plus a tag is the pattern for content that changes only when a person edits it. A short profile suits lists that drift through the day.
What you cannot cache: request data#
A cached scope has to produce the same output for the same inputs, so it can't read cookies() or headers() directly. Read them outside, then pass the values in:
import { cookies } from 'next/headers'
export default async function Page() {
const locale = (await cookies()).get('locale')?.value ?? 'en'
return <Prices locale={locale} />
}
async function Prices({ locale }: { locale: string }) {
'use cache'
cacheLife('hours')
return <PriceTable data={await getPrices(locale)} />
}If you truly cannot refactor, there is a "use cache: private" variant, and "use cache: remote" lets a platform supply a shared cache handler. Both have trade-offs, so reach for them last.
Everything else streams#
Components that need fresh data on every request simply don't use the directive. Wrap them in <Suspense> so the cached shell can ship immediately:
import { Suspense } from 'react'
export default function Page() {
return (
<>
<Header /> {/* prerendered into the shell */}
<Suspense fallback={<p>Loading…</p>}>
<LiveStats /> {/* fetched per request */}
</Suspense>
</>
)
}Without the boundary, the dev overlay flags a blocking route and tells you to add one. Note that <Suspense> does not make a component dynamic by itself; synchronous work still completes during prerendering.
A migration checklist#
- Enable
cacheComponentsand runnext dev, which surfaces blocking routes as you navigate. - Put
"use cache"and an explicitcacheLifeon the data functions you used to rely on implicit caching for. - Add
cacheTagwherever a mutation should refresh the data. - Wrap per-request components in
<Suspense>.
The mental model is smaller than the old one: cache what you mark, stream what you don't.
fig. B.1keep reading
Related posts
· 4 min
From Web Developer to AI Engineer: The Skills That Transfer
You don't need a PhD to build AI features. A practical roadmap for Next.js and Python developers: tool calls, retrieval, evals, streaming and cost control.
Read →· 3 min
Next.js 16 proxy.ts: What Replaced middleware.ts and How to Migrate
Next.js 16 renamed Middleware to Proxy. See what changed, what the Node.js runtime means for you, how to migrate in one command, and what not to put in it.
Read →· 4 min
Is the AI Boom a Bubble? A Developer's Read of the Numbers
Hundreds of billions are going into AI infrastructure. Here is what the spending, the revenue and the skeptics say, and what it means for developers.
Read →