Next.js
Next.js Caching and Revalidation: What Lives Where
How Next.js 16 caching works with Cache Components: use cache, cacheLife, cacheTag, revalidateTag vs updateTag, and how to verify each layer.
In Next.js 16, caching is opt-in through Cache Components: you mark async functions or components with the use cache directive, give them a lifetime with cacheLife, and tag them with cacheTag. Revalidation is then either time-based, through that lifetime, or on demand, through revalidateTag, updateTag or revalidatePath after a mutation.
Quick answer
- Enable
cacheComponents: trueinnext.config.ts, then add'use cache'pluscacheLife(...)to data functions or components. - Time-based revalidation comes from the
cacheLifeprofile (stale,revalidate,expire). Without one, thedefaultprofile applies: 5 minutes stale, 15 minutes revalidate, never expires. - On-demand revalidation: tag with
cacheTag, then callupdateTagin a Server Action (read-your-own-writes) orrevalidateTag(tag, 'max')(stale-while-revalidate). - Cached output lives in three places: prerendered HTML, a server cache store, and the browser. Each is scoped to one deployment.
- Verify with
Cache-Controlheaders,NEXT_PRIVATE_DEBUG_CACHE=1and a timestamp probe, on a production build, not innext dev.
Version assumptions: this article follows the official documentation as read in 2026-11 for Next.js 16.x, where Cache Components shipped in 16.0.0. If your project does not set cacheComponents, you are on the older model, covered near the end.
The model: what is cached where
The Caching guide describes a cached function's output as an RSC payload, and everything else is a copy of that payload in a different place:
| Layer | What is stored | Controlled by | Survives a new deploy? |
|---|---|---|---|
| Prerendered HTML (static shell) | Build-time output, plus ISR upgrades | revalidate and expire in cacheLife | No |
| Server cache store | use cache entries computed at runtime | cacheLife, tags, cacheHandlers | No |
| Browser | Payload from navigations and prefetches | stale in cacheLife | No |
Two details are easy to miss. First, the default server store is per-instance memory. On serverless, the use cache reference says entries typically do not persist across requests, while build-time caching works normally. Self-hosted, entries persist and you size them with cacheMaxMemorySize.
Second, the cache key includes the build ID (or deploymentId if configured), the function ID and the serializable arguments. Every deploy therefore starts cold, even for use cache: remote.
Caching data and components
Enable the flag first. Assumes Next.js 16.x, App Router, Node.js runtime:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
cacheComponents: true,
}
export default nextConfig
The docs also describe a partialPrefetching option and warn if it is unset, so check the cacheComponents reference for your exact release.
Now a cached, tagged data function. The db object is a placeholder for your own data layer, so this is illustrative code, not a runnable project:
// app/lib/posts.ts (illustrative)
import { cacheLife, cacheTag } from 'next/cache'
export async function getPosts() {
'use cache'
cacheLife('hours')
cacheTag('posts')
return db.query('SELECT id, title FROM posts ORDER BY created_at DESC')
}
Arguments become part of the cache key, so getPost(id) gets one entry per id. Arguments and return values must be serializable: class instances, symbols and URL objects are rejected.
Runtime data does not go inside the cache
A cached scope cannot call cookies(), headers() or read searchParams, including through a helper it calls. Read them outside and pass the value in:
// app/profile/page.tsx (illustrative)
import { cookies } from 'next/headers'
import { Suspense } from 'react'
export default function Page() {
return (
<Suspense fallback={<p>Loading...</p>}>
<ProfileContent />
</Suspense>
)
}
async function ProfileContent() {
const session = (await cookies()).get('session')?.value ?? ''
return <CachedProfile sessionId={session} />
}
async function CachedProfile({ sessionId }: { sessionId: string }) {
'use cache'
// sessionId is part of the cache key
const data = await fetchUserData(sessionId)
return <div>{data.name}</div>
}
The docs note that this runtime entry is in-memory by default, so on serverless it may recompute per request. If you need durability across instances, use cache: remote with a cache handler exists, at the cost of a network round trip per lookup.
Revalidation: time-based and on-demand
Revalidating lists the built-in cacheLife profiles:
| Profile | stale | revalidate | expire |
|---|---|---|---|
default | 5m | 15m | never |
seconds | 30s | 1s | 60s |
minutes | 5m | 1m | 1h |
hours | 5m | 1h | 1d |
days | 5m | 1d | 1w |
weeks | 5m | 1w | 30d |
max | 5m | 30d | 1y |
stale is the browser window, revalidate is when the server refreshes in the background, and expire is the hard limit after which a request blocks. A very short lifetime (the seconds profile, revalidate: 0, or expire under 5 minutes) is excluded from prerenders and becomes a dynamic hole instead.
updateTag vs revalidateTag
updateTag | revalidateTag | |
|---|---|---|
| Where | Server Actions only | Server Actions and Route Handlers |
| Behavior | Expires immediately | Stale-while-revalidate |
| Use for | User must see own change | Background refresh is fine |
The revalidateTag reference documents the signature as revalidateTag(tag, profile). The single-argument form is deprecated and behaves like { expire: 0 }. Two behaviors matter in practice:
- A revalidation is triggered by the next request, not by the call itself, so pages refresh as they are visited.
- Tags are case-sensitive and limited to 256 characters. A longer tag is never assigned, so revalidating it silently does nothing.
A Server Action that creates content and shows it immediately:
// app/actions.ts (illustrative)
'use server'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
export async function createPost(formData: FormData) {
const post = await db.post.create({
data: { title: String(formData.get('title')) },
})
updateTag('posts')
redirect(`/posts/${post.id}`)
}
Webhooks from a CMS or another service
updateTag is not available outside Server Actions. The docs say to pass { expire: 0 } when the caller needs data expired immediately, or 'max' when stale-while-revalidate is acceptable. A minimal Route Handler, illustrative and without production-grade secret handling:
// app/api/revalidate/route.ts (illustrative)
import type { NextRequest } from 'next/server'
import { revalidateTag } from 'next/cache'
export async function POST(request: NextRequest) {
if (request.headers.get('x-webhook-secret') !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: 'unauthorized' }, { status: 401 })
}
const { tag } = await request.json()
if (typeof tag !== 'string') {
return Response.json({ error: 'missing tag' }, { status: 400 })
}
revalidateTag(tag, 'max')
return Response.json({ revalidated: true, now: Date.now() })
}
Webhook senders retry and deliver duplicates, but revalidating a tag twice is harmless, so this endpoint is naturally idempotent. For the delivery side, see Webhooks vs polling: how to design a reliable integration.
Prefer tags over revalidatePath. The docs call tag-based revalidation more precise, and the self-hosting guide describes revalidatePath as a convenience layer over a special per-page tag.
How to verify it
Do this on a production build. In development, pages render on demand and are not cached, so next dev proves nothing about caching.
Build and start
Run
npm run build && npm run start. The build output marks which routes are static, partially prerendered or dynamic.Check Cache-Control
The self-hosting guide says fully prerendered pages send
Cache-Control: public, and pages that access dynamic APIs sendprivate, no-cache, no-store, max-age=0, must-revalidate.# curl is available on macOS, Linux and current Windows 10/11 curl -sI http://localhost:3000/blog | grep -i cache-controlProbe with a timestamp
Temporarily render
Date.now()from inside ause cachefunction. Reload: the value should stay fixed until the lifetime passes or you invalidate the tag. If it changes on every request, the entry is not being stored (serverless, a too-short lifetime, or Draft Mode).Turn on cache logging
The
use cachereference documentsNEXT_PRIVATE_DEBUG_CACHE=1for verbose cache logs.# macOS / Linux (sh, bash) NEXT_PRIVATE_DEBUG_CACHE=1 npm run start# Windows PowerShell $env:NEXT_PRIVATE_DEBUG_CACHE = "1"; npm run startInvalidate and re-check
POST to the webhook with a tag. The first request afterwards serves the stale value while it refreshes (with
'max'), the second shows the new one. With{ expire: 0 }the first request already blocks and returns fresh data.
Failure modes worth knowing
- Multiple instances. By default
revalidateTag()on one instance only invalidates that instance's cache. The self-hosting guide says to implementrefreshTags()in a custom cache handler backed by shared storage. - Draft Mode. Cached functions re-execute on every request and results are not saved.
- Build hangs. Passing a promise from
cookies()into a cached component makes prerender wait until a 50 second timeout. Await the value outside and pass the string. - Reverse proxies. Streaming needs buffering disabled end to end, or the static shell and streamed parts arrive together.
Where this meets backend work
Cache invalidation is an integration problem as much as a framework one: something upstream changes, a signal arrives, and several instances must agree on what is stale. At Legacies, Alexandru Talnaci is co-founder and Technical Lead, with responsibility for backend architecture, integrations and product stability, which is the layer where webhook delivery and invalidation contracts get decided. The Legacies section has more context, and Selected work shows his full-stack projects.
Frequently Asked Questions
Is Next.js caching on by default?
Not for dynamic data. With Cache Components, nothing is cached until you add use cache, and uncached async work must sit behind a Suspense boundary or stream at request time. Synchronous, predictable work such as module imports is prerendered automatically.
What is the difference between revalidateTag and revalidatePath?
revalidateTag invalidates every cached entry carrying a tag, across all pages that use it. revalidatePath invalidates a specific page or layout path. The docs recommend tags where possible because they over-invalidate less.
Why does my cached page still show old data after revalidateTag?
With the 'max' profile, the first request after invalidation is intentionally served stale while the refresh runs. Also check that the tag string matches exactly (it is case-sensitive), that it is under 256 characters, and that you are not running several instances with the default in-memory store.
Can I use updateTag in a Route Handler?
No, updateTag works only in Server Actions. In a Route Handler, call revalidateTag(tag, { expire: 0 }) if the data must be gone immediately, or revalidateTag(tag, 'max') if stale-while-revalidate is acceptable.