💾 Chapter 14: Caching & use cache
The caching model Next.js 16 was built around: one directive, explicit lifetimes, and no more guessing what's cached.
📖 Plain English Explanation
Caching is storing the result of expensive work so you don't repeat it. Simple idea, and Next.js has historically made it complicated.
Next.js 13 and 14 cached aggressively and implicitly. fetch was cached by default. Route segments were cached by default. Developers spent an enormous amount of time discovering their data was stale and hunting for the flag that turned caching off. The complaints were loud enough that Next.js 15 reversed the defaults, and Next.js 16 replaced the whole model.
The new model is built on one principle:
Nothing is cached unless you say so, and when you say so, you say for how long.
The tool is a directive:
async function getProducts() {
'use cache'
cacheLife('hours')
return db.product.findMany()
}
Three lines: cache this, for roughly an hour. No hidden defaults, no framework guessing.
This chapter covers use cache and its supporting APIs. Chapter 15 covers invalidating what you've cached. Chapter 16 covers the bigger picture — how caching feeds Partial Prerendering.
⚙️ Turning it on
use cache, cacheLife, and cacheTag are all part of Cache Components, which is opt-in:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
cacheComponents: true,
}
module.exports = nextConfig
⚠️ Changed in Next.js 16
cacheComponentsreplaces three removed experimental flags:js// ❌ all removed in Next.js 16 experimental: { ppr: true } experimental: { dynamicIO: true } experimental: { useCache: true } // ✅ cacheComponents: trueThis is not a rename. Enabling
cacheComponentschanges how rendering works and will surface build errors for uncached data that isn't inside a<Suspense>boundary. Adopt it deliberately, not as a find-and-replace. Chapter 16 walks through the migration.
Without this flag you're on the previous model — fetch options and unstable_cache, covered at the end of this chapter.
🏷️ use cache — three placements
Every cached function or component must be async.
Function level
// lib/data.ts
import { cacheLife } from 'next/cache'
export async function getProducts() {
'use cache'
cacheLife('hours')
return db.product.findMany({ where: { active: true } })
}
// lib/data.js
import { cacheLife } from 'next/cache'
export async function getProducts() {
'use cache'
cacheLife('hours')
return db.product.findMany({ where: { active: true } })
}
Component level
// app/components/ProductGrid.tsx
import { cacheLife, cacheTag } from 'next/cache'
export async function ProductGrid({ category }: { category: string }) {
'use cache'
cacheLife('hours')
cacheTag(`products-${category}`)
const products = await db.product.findMany({ where: { category } })
return (
<div className="grid grid-cols-4 gap-4">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
)
}
// app/components/ProductGrid.js
import { cacheLife, cacheTag } from 'next/cache'
export async function ProductGrid({ category }) {
'use cache'
cacheLife('hours')
cacheTag(`products-${category}`)
const products = await db.product.findMany({ where: { category } })
return (
<div className="grid grid-cols-4 gap-4">
{products.map((p) => (
<ProductCard key={p.id} product={p} />
))}
</div>
)
}
Caching a component caches its rendered output, not just its data. Everything it renders is stored as an RSC payload and replayed.
File level
// lib/settings.ts
'use cache' // ← applies to every export in this file
import { cacheLife } from 'next/cache'
export async function getSiteConfig() {
cacheLife('max')
return db.config.findFirst()
}
export async function getNavigation() {
cacheLife('max')
return db.navItem.findMany()
}
At file level, every export must be async.
Caching a whole route
Put use cache at the top of both the layout and the page — they're separate cache entries:
// app/blog/layout.tsx
'use cache'
import { cacheLife } from 'next/cache'
export default async function Layout({ children }: { children: React.ReactNode }) {
cacheLife('days')
return <div className="prose">{children}</div>
}
// app/blog/page.tsx
'use cache'
import { cacheLife, cacheTag } from 'next/cache'
export default async function Page() {
cacheLife('days')
cacheTag('posts')
const posts = await getPosts()
return <PostList posts={posts} />
}
🔑 Cache keys — what makes an entry unique
This is the part that determines whether your cache actually works. The key is built from:
- Build ID — changes every deploy, so a new deploy starts with an empty cache
- Function ID — a hash of the function's location and signature
- Serializable arguments — the function's parameters or the component's props
- Closed-over variables — anything captured from an outer scope
That fourth one surprises people:
async function Component({ userId }: { userId: string }) {
const getData = async (filter: string) => {
'use cache'
// Cache key includes BOTH userId (closure) and filter (argument)
return fetch(`/api/users/${userId}/data?filter=${filter}`)
}
return getData('active')
}
userId isn't a parameter, but it's captured from the enclosing scope, so it becomes part of the key. Every user gets their own cache entry — which is correct here, but be aware it's happening.
Practical consequence: a cached function with a wide argument space produces a wide cache. getUser(id) across a million users is a million entries. That's usually fine, but a function keyed on a full search query string may never hit.
⏱️ cacheLife — the lifetime
Every cache directive should be paired with a cacheLife call. It takes a profile name:
import { cacheLife } from 'next/cache'
export async function getPost(slug: string) {
'use cache'
cacheLife('days')
return db.post.findUnique({ where: { slug } })
}
The three timings
| Property | Meaning |
|---|---|
stale | How long the browser serves cached content without asking the server |
revalidate | How long before the server regenerates in the background (stale-while-revalidate) |
expire | Maximum age — after this with no traffic, the next request waits for a fresh render |
The built-in profiles
| Profile | Use for | stale | revalidate | expire |
|---|---|---|---|---|
default | Standard content | 5 min | 15 min | never |
seconds | Real-time (prices, scores) | 30 sec | 1 sec | 1 min |
minutes | Feeds, news | 5 min | 1 min | 1 hour |
hours | Inventory, weather | 5 min | 1 hour | 1 day |
days | Blog posts, articles | 5 min | 1 day | 1 week |
weeks | Podcasts, newsletters | 5 min | 1 week | 30 days |
max | Legal pages, archives | 5 min | 30 days | 1 year |
Pick by how often the content actually changes:
cacheLife('seconds') // live stock ticker
cacheLife('minutes') // social feed
cacheLife('hours') // product inventory
cacheLife('days') // published blog post
cacheLife('max') // terms of service
Custom profiles
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
cacheLife: {
editorial: {
stale: 600, // 10 minutes
revalidate: 3600, // 1 hour
expire: 86400, // 1 day
},
},
}
export default nextConfig
export async function getArticle(id: string) {
'use cache'
cacheLife('editorial')
return db.article.findUnique({ where: { id } })
}
Omitted properties inherit from default. TypeScript autocomplete picks up your custom names.
Inline profiles
For one-offs, or when the lifetime comes from the data itself:
export async function getOffer(id: string) {
'use cache'
const offer = await db.offer.findUnique({ where: { id } })
cacheLife({
revalidate: offer?.ttlSeconds ?? 3600,
})
return offer
}
Conditional lifetimes
You can call cacheLife in different branches, as long as exactly one runs per invocation:
import { cacheLife, cacheTag } from 'next/cache'
export async function getPost(slug: string) {
'use cache'
const post = await fetchPost(slug)
cacheTag(`post-${slug}`)
if (!post) {
cacheLife('minutes') // might get published soon — check back often
return null
}
cacheLife('days') // published content is stable
return post
}
Always set it explicitly
Omitting cacheLife applies the default profile — but more importantly, it makes the outer cache's lifetime depend on nested caches:
export default async function Dashboard() {
'use cache'
// No cacheLife → default (15 min revalidate)
// If Widget has a 5-min lifetime → Dashboard drops to 5 min
// If Widget has a 1-hour lifetime → Dashboard stays at 15 min
return <div><Widget /></div>
}
That's invisible action at a distance, potentially through a third-party dependency. With an explicit cacheLife, the outer scope always wins and you can reason about a function by reading it.
Next.js enforces this in one case: nesting a short-lived cache (revalidate 0, or expire under 5 minutes) inside a cache with no explicit cacheLife is a build error, because the short lifetime would silently propagate outward.
🏷️ cacheTag — labels for invalidation
cacheLife handles time. cacheTag handles events.
// lib/data.ts
import { cacheLife, cacheTag } from 'next/cache'
export async function getPost(slug: string) {
'use cache'
cacheLife('max') // long lifetime...
cacheTag(`post-${slug}`) // ...because we'll invalidate explicitly
cacheTag('posts') // tag it twice — specific and broad
return db.post.findUnique({ where: { slug } })
}
// lib/data.js
import { cacheLife, cacheTag } from 'next/cache'
export async function getPost(slug) {
'use cache'
cacheLife('max')
cacheTag(`post-${slug}`)
cacheTag('posts')
return db.post.findUnique({ where: { slug } })
}
Then invalidate from a Server Action:
// app/actions.ts
'use server'
import { updateTag } from 'next/cache'
export async function editPost(slug: string, data: PostInput) {
await db.post.update({ where: { slug }, data })
updateTag(`post-${slug}`) // this post, immediately
}
⚠️ Changed in Next.js 16
cacheLifeandcacheTagare now stable. Drop theunstable_prefix and the import aliases:ts// ❌ Next.js 15 import { unstable_cacheLife as cacheLife, unstable_cacheTag as cacheTag, } from 'next/cache' // ✅ Next.js 16 import { cacheLife, cacheTag } from 'next/cache'
The combination — long cacheLife plus a cacheTag you invalidate on write — is the ideal setup for content that changes rarely but must be correct when it does. Chapter 15 covers the invalidation side in full.
🚫 The constraint you'll hit first
Cached functions cannot read request-time data.
// ❌ Error: next-request-in-use-cache
async function getUserData() {
'use cache'
const session = (await cookies()).get('session') // not allowed
return db.user.findUnique({ where: { id: session.userId } })
}
Off-limits inside a use cache scope:
cookies()headers()searchParamsparams(unless prerendered viagenerateStaticParams)
The restriction follows the call stack — a helper the cached function calls that reads cookies fails the same way.
This makes sense: a cache is shared, and request data is per-user. Caching a value derived from someone's session and serving it to someone else is a data leak.
The fix: read outside, pass in
// app/profile/page.tsx
import { cookies } from 'next/headers'
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'
export default function Page() {
return (
<Suspense fallback={<div>Loading…</div>}>
<ProfileContent />
</Suspense>
)
}
// Not cached — reads the request
async function ProfileContent() {
const sessionId = (await cookies()).get('session')?.value
return <CachedProfile sessionId={sessionId} />
}
// Cached — receives the value as an argument
async function CachedProfile({ sessionId }: { sessionId?: string }) {
'use cache'
cacheLife('minutes')
const data = await fetchUserData(sessionId)
return <div>{data.name}</div>
}
// app/profile/page.js
import { cookies } from 'next/headers'
import { Suspense } from 'react'
import { cacheLife } from 'next/cache'
export default function Page() {
return (
<Suspense fallback={<div>Loading…</div>}>
<ProfileContent />
</Suspense>
)
}
async function ProfileContent() {
const sessionId = (await cookies()).get('session')?.value
return <CachedProfile sessionId={sessionId} />
}
async function CachedProfile({ sessionId }) {
'use cache'
cacheLife('minutes')
const data = await fetchUserData(sessionId)
return <div>{data.name}</div>
}
sessionId becomes part of the cache key, so each session gets its own entry. Explicit, safe, and readable.
Draft Mode is the exception
draftMode() can be read inside use cache, and when draft mode is on, cached functions re-execute every request and don't write to the cache:
import { draftMode } from 'next/headers'
async function Content() {
'use cache'
const { isEnabled } = await draftMode()
const url = isEnabled ? DRAFT_API : PROD_API
return <article>{await fetchFrom(url)}</article>
}
📦 Serialization rules
Arguments and return values must be serializable — and the rules differ between the two.
Arguments (Server Component serialization, stricter):
✅ string, number, boolean, null, undefined
✅ plain objects, arrays
✅ Date, Map, Set, TypedArray, ArrayBuffer
✅ React elements — pass-through only
❌ class instances, functions, Symbol, WeakMap, URL
Return values (Client Component serialization, looser): the same, plus JSX elements. That's why a cached component can return markup.
Pass-through: children and actions
You can accept non-serializable values as long as you don't inspect them:
async function CachedShell({
header,
children,
}: {
header: React.ReactNode
children: React.ReactNode
}) {
'use cache'
cacheLife('hours')
const cachedData = await getExpensiveData()
return (
<div>
{header}
<PrerenderedContent data={cachedData} />
{children} {/* passed straight through, not part of the key */}
</div>
)
}
// app/page.tsx
export default async function Page() {
const liveData = await getLiveData() // NOT cached
return (
<CachedShell header={<h1>Dashboard</h1>}>
<LiveWidget data={liveData} /> {/* dynamic, inside a cached shell */}
</CachedShell>
)
}
The expensive shell is cached; the live widget isn't. This composition is what makes Cache Components genuinely useful rather than all-or-nothing.
Server Actions pass through the same way — just don't call them inside the cached function:
async function CachedForm({ action }: { action: () => Promise<void> }) {
'use cache'
// Don't invoke action here — just hand it to the client
return <ClientButton action={action} />
}
🌍 The three cache directives
| Directive | Where it's stored | Can read cookies/headers | Use for |
|---|---|---|---|
use cache | In-memory on the server (per instance) | ❌ | Shared data, the default choice |
use cache: remote | A durable shared store (Redis, KV) | ❌ | High-hit-rate data across instances |
use cache: private | The user's browser | ✅ | Per-user data you can't refactor |
use cache and serverless
Worth understanding, because it changes what you should expect:
| Environment | Runtime behavior |
|---|---|
| Serverless | Each request may hit a fresh instance. In-memory entries often don't persist between requests. Build-time caching still works. |
| Self-hosted | A long-lived process means entries persist across requests as expected. |
So on Vercel or Lambda, use cache primarily helps by putting results into the prerendered static shell at build time. For durable runtime caching across instances you need use cache: remote, which costs a network round-trip and usually platform fees. Only worth it at a high hit rate.
use cache: private
For per-user data where refactoring to pass arguments isn't practical:
async function UserGreeting() {
'use cache: private'
cacheLife('minutes')
const theme = (await cookies()).get('theme')?.value // allowed here
return <p>Theme: {theme}</p>
}
The result is cached in the user's browser only — never in a shared server store. This is what makes reading cookies safe.
Prefer the read-outside-pass-in pattern where you can. Reach for private when compliance rules or an unrefactorable codebase force your hand.
Note: no cache survives a deploy
The build ID is part of every cache key, so a new deployment starts cold — including remote entries. For data that must persist across deploys, use unstable_cache or the fetch cache.
🗄️ The previous model (without Cache Components)
If cacheComponents is off, you're on the older APIs. Still supported, still common in existing codebases.
fetch options
// No cache — the DEFAULT in Next.js 15/16
await fetch(url, { cache: 'no-store' })
// Cache indefinitely
await fetch(url, { cache: 'force-cache' })
// Cache with time-based revalidation
await fetch(url, { next: { revalidate: 3600 } })
// Cache with tags for on-demand invalidation
await fetch(url, { next: { tags: ['posts'] } })
⚠️ Changed in Next.js 15
fetchused to be cached by default. Since Next.js 15, it isn't. If a tutorial tells you to addcache: 'no-store'to get fresh data, that's the default now.
Route segment config
// app/products/page.tsx
export const dynamic = 'force-dynamic' // never prerender
export const revalidate = 3600 // ISR — regenerate hourly
export const fetchCache = 'force-no-store'
unstable_cache
The pre-use cache way to cache a non-fetch function:
import { unstable_cache } from 'next/cache'
export const getProducts = unstable_cache(
async (category: string) => db.product.findMany({ where: { category } }),
['products'], // key parts
{ revalidate: 3600, tags: ['products'] }
)
It still works and, unlike use cache, persists across deploys. But use cache is clearer, composable, and integrated with prerendering. Prefer it for new code.
⚠️ Common Pitfalls
1. Forgetting cacheComponents: true
Error: "use cache" is not enabled
The directive does nothing without the flag.
2. Reading cookies inside use cache
Error: next-request-in-use-cache
The nasty version: on a dynamically rendered route this can pass next build and only fail under next start. Test production builds.
3. Omitting cacheLife
Not an error, but it makes the lifetime implicit and lets nested caches silently shorten it. Set it every time.
4. Caching per-user data with plain use cache
If a user ID isn't part of the cache key, one user's data gets served to another. Either pass the ID as an argument, or use use cache: private.
5. Expecting in-memory caching to work on serverless
Instances are ephemeral. Plain use cache mainly helps at build time there. Use remote if you need runtime persistence across instances.
6. Expecting cache to survive a deploy
It doesn't. The build ID is in every key.
7. Passing a class instance
// ❌ Cannot serialize class instance
async function Card({ user }: { user: UserModel }) {
'use cache'
}
// ✅
async function Card({ id, name }: { id: string; name: string }) {
'use cache'
}
8. Build hangs for 50 seconds
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".
You've passed a promise that resolves to request data into a cached scope — as a prop, via a closure, or through a shared Map. Await the runtime value outside and pass the resolved primitive in.
9. Caching something too short-lived to matter
cacheLife('seconds') has a 1-minute expire, which excludes it from prerenders. If the data changes every request, don't cache it — wrap it in <Suspense> and stream it.
🎯 When & Why to Use
'use cache' when:
✅ The result is the same for many users
✅ It's expensive (slow query, external API, heavy computation)
✅ Slightly stale is acceptable
✅ You want it in the prerendered static shell
'use cache: private' when:
✅ Per-user data that must read cookies/headers directly
✅ You can't refactor to pass values as arguments
'use cache: remote' when:
✅ Serverless, and you need entries shared across instances
✅ The hit rate justifies a network round-trip and platform cost
Don't cache when:
❌ Data must be fresh every request → <Suspense> + stream it
❌ It's cheap to compute
❌ The cache key would be so wide it never hits
Choosing a lifetime:
Changes every second? → don't cache; stream it
Every minute? → cacheLife('minutes')
A few times a day? → cacheLife('hours')
Daily? → cacheLife('days')
Only when someone edits? → cacheLife('max') + cacheTag + updateTag on write
🏋️ Mini Practice Problems
Problem 1: Fix the error
import { cookies } from 'next/headers'
export async function getDashboard() {
'use cache'
const userId = (await cookies()).get('uid')?.value
return db.dashboard.findUnique({ where: { userId } })
}
Name the error and give two different fixes.
Problem 2: Pick the profile
- A. Live cryptocurrency price
- B. A published blog post that can be edited by its author
- C. The site's navigation menu
- D. A product's stock count
- E. A "trending this week" list
For each: which cacheLife profile, and does it also need a cacheTag?
Problem 3: Cache key analysis
How many cache entries does this produce for 1,000 users each viewing 5 categories?
async function Component({ userId }: { userId: string }) {
const load = async (category: string) => {
'use cache'
return fetch(`/api/${userId}/items?c=${category}`)
}
return load('shoes')
}
Is this a good use of use cache? Why or why not?
Problem 4: Compose it
A product page needs:
- Product details — change a few times a day, must update instantly when an admin edits
- Reviews — expensive query, an hour stale is fine
- "In your cart" indicator — per-user, must be current
- Recommendations — same for everyone, updated nightly
Write the page. Say which parts are cached with what profile and tags, and which stream.
💼 Interview Notes
Common Questions
Q: What changed about caching in Next.js 16?
Implicit caching was replaced by explicit opt-in. fetch is no longer cached by default (that changed in 15). The use cache directive, cacheLife, and cacheTag became the model, gated behind cacheComponents: true, which also replaced experimental.ppr, dynamicIO, and useCache.
Q: What determines a cache entry's key? Build ID, a hash of the function's identity, its serializable arguments, and any variables it closes over from an outer scope. The closure part is the one people miss.
Q: Why can't a cached function read cookies()?
A cache entry is shared. Deriving it from one user's request and serving it to another would leak data. Read request values outside the cached scope and pass them in as arguments, which makes them part of the key.
Q: What's the difference between stale, revalidate, and expire?
stale is how long the browser serves its copy without checking the server. revalidate is when the server regenerates in the background while still serving the old copy. expire is the hard ceiling — past it, the next request waits for a fresh render.
Q: Why should you always call cacheLife explicitly?
Without it, the default profile applies and nested caches with shorter lifetimes can silently shorten the outer one — possibly from a third-party dependency. An explicit call always wins, so a function's behavior is readable from the function itself.
Q: When would you use use cache: remote over use cache?
On serverless, where in-memory entries don't survive between requests. remote uses a durable shared store, at the cost of a network round-trip and platform fees — worth it only at a high hit rate.
Q: Does the cache survive a deployment?
No. The build ID is part of every cache key, including remote entries. unstable_cache and the fetch cache do persist across deploys.
🏢 Asked at Companies
- Vercel: "Design the caching for a product page: catalog, live stock, personalized recommendations, and reviews."
- Shopify: "A customer reports seeing another customer's name on the dashboard. What caching mistake causes that?"
- Stripe: "Explain stale-while-revalidate and where it shows up in
cacheLife." - Netflix: "When is caching the wrong answer, and what do you do instead?"
📊 Visual Memory Aid
THE CACHE KEY
┌─────────────────────────────────┐
│ build ID (per deploy) │
│ function ID (code location) │
│ arguments (props/params) │
│ closed-over vars ← easy to miss │
└─────────────────────────────────┘
│
└──► one entry per unique combination
THE THREE TIMINGS
0 ─────────────────────────────────────────────► time
│ stale │ revalidate │ expire
│ browser serves │ server serves │ next request
│ without asking │ old + rebuilds │ waits for fresh
│ │ in background │
PROFILES AT A GLANCE
seconds ──► live data (1s revalidate)
minutes ──► feeds, news (1m)
hours ──► inventory (1h)
days ──► blog posts (1d)
weeks ──► newsletters (1w)
max ──► legal, archive (30d) ← pair with cacheTag
THE DIRECTIVES
'use cache' server memory no cookies shared
'use cache: remote' durable store no cookies shared
'use cache: private' browser only cookies OK per-user
READ OUTSIDE, PASS IN
❌ async function f() {
'use cache'
const id = (await cookies()).get('uid') ← error
}
✅ async function Outer() { (not cached)
const id = (await cookies()).get('uid')
return <Inner userId={id} />
}
async function Inner({ userId }) {
'use cache' ← userId is in the key
}
🎯 Key Takeaways
- Nothing is cached unless you say so.
use cache+cacheLifeis the whole model, gated behindcacheComponents: true. - Arguments and closures form the cache key. A value captured from an outer scope silently multiplies your cache entries.
- Always call
cacheLifeexplicitly. Otherwise nested caches can shorten the outer lifetime from somewhere you'd never think to look. - Cached scopes cannot read
cookies(),headers(), orsearchParams. Read them outside and pass the value in — that's the pattern, not a workaround. - On serverless, plain
use cachemainly helps at build time. In-memory entries don't survive between requests; useuse cache: remotewhen you need durable shared caching.
Next Chapter: Revalidation & ISR →
Practice: Take a page with three data sources of different volatility. Cache each with an appropriate cacheLife, tag the one that changes on write, and use NEXT_PRIVATE_DEBUG_CACHE=1 npm run start to watch the hits and misses in the log.