Revalidation and Cache Invalidation
The idea in one sentence
Section titled “The idea in one sentence”Once data is in the Data Cache you invalidate it two ways — time-based (let it go stale after N seconds) or on-demand (revalidateTag / revalidatePath after a mutation) — and both also refresh the Full Route Cache for affected routes.
Time-based revalidation
Section titled “Time-based revalidation”Give a cached fetch a lifetime with next: { revalidate: N }, or set it for a whole route segment with export const revalidate. After the window passes, the next request triggers a background refresh.
// Per fetch: this data is at most 60 seconds staleawait fetch('https://api.example.com/posts', { next: { revalidate: 60 } })// app/blog/page.tsx — revalidate the entire segment every hourexport const revalidate = 3600On-demand revalidation with tags
Section titled “On-demand revalidation with tags”For data that changes on an event, not a clock, tag the fetch, then call revalidateTag(tag, profile) after the mutation. Every fetch carrying that tag is marked stale in the Data Cache. The second argument is required in Next.js 16 (the one-argument form is deprecated and a TypeScript error): it is a cacheLife profile that says how long stale data may still be served while the refresh runs — 'max' is the recommended value. When the user must see their own write immediately, call updateTag(tag) from the Server Action instead; it expires the entry so the next request waits for fresh data.
export async function getPosts() { const res = await fetch('https://api.example.com/posts', { next: { tags: ['posts'] }, }) return res.json()}'use server'import { revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) { await fetch('https://api.example.com/posts', { method: 'POST', body: JSON.stringify({ title: formData.get('title') }), }) // Mark every fetch tagged 'posts' stale; 'max' = serve stale while refreshing in the background revalidateTag('posts', 'max')}revalidatePath vs. revalidateTag
Section titled “revalidatePath vs. revalidateTag”Both run inside a Server Action or Route Handler. revalidateTag targets data by tag, wherever it is fetched. revalidatePath targets a route and clears everything cached for it — reach for it when you do not know the tags.
'use server'import { revalidatePath } from 'next/cache'
export async function updateProfile() { // Re-render and refetch everything for this exact route revalidatePath('/profile')}revalidatePath('/', 'layout'): the blast radius
Section titled “revalidatePath('/', 'layout'): the blast radius”revalidatePath takes a type parameter, 'page' or 'layout'. Passing '/' with 'layout' is the largest single revalidation call in the API: the bundled docs describe it as purging the Client Cache and invalidating all cached data for revalidation on the next visit — the root layout wraps the entire app, so its blast radius is every route beneath it. Reach for it only for something that genuinely touches everything (a full content reindex, a locale switch), never as a reflex “just to be safe” call after an ordinary mutation.
'use server'import { revalidatePath } from 'next/cache'
export async function purgeEverything() { // Invalidates the root layout — every page in the app revalidates on next visit, // and the Client Cache is purged immediately. The largest revalidatePath call there is. revalidatePath('/', 'layout')}On-demand revalidation from a Route Handler with a secret
Section titled “On-demand revalidation from a Route Handler with a secret”A Server Action’s revalidation is triggered by the user’s own mutation, but content that changes outside your app — a CMS edit, a webhook from a payment provider — needs a Route Handler that anyone on the internet can technically call. Without a check, that Route Handler is an open invalidation endpoint. The standard fix, straight from the docs’ on-demand revalidation pattern, is a shared secret passed as a query parameter and compared against a server-only environment variable before doing anything:
import { revalidateTag } from 'next/cache'import { NextRequest, NextResponse } from 'next/server'
// Called by a CMS webhook: /api/revalidate?secret=...&tag=postsexport async function POST(request: NextRequest) { const secret = request.nextUrl.searchParams.get('secret') if (secret !== process.env.REVALIDATE_SECRET) { return NextResponse.json({ message: 'Invalid secret' }, { status: 401 }) }
const tag = request.nextUrl.searchParams.get('tag') if (!tag) { return NextResponse.json({ message: 'Missing tag' }, { status: 400 }) }
revalidateTag(tag, 'max') return NextResponse.json({ revalidated: true, tag, now: Date.now() })}The CMS (or any other webhook sender) is configured with the URL plus the secret; REVALIDATE_SECRET lives only in the server’s environment, never in a NEXT_PUBLIC_ variable, so it never reaches the client bundle. Anyone calling the endpoint without the right secret gets a 401 and nothing revalidates.
Does the x-nextjs-cache header still exist in Next.js 16?
Section titled “Does the x-nextjs-cache header still exist in Next.js 16?”Yes — checked with curl -I against a next start server in this course’s probe, on a fully static App Router route, in both caching models:
HTTP/1.1 200 OKx-nextjs-cache: HITx-nextjs-prerender: 1x-nextjs-stale-time: 300Cache-Control: s-maxage=31536000x-nextjs-cache (values HIT / MISS / STALE / REVALIDATED, documented for the older Pages Router ISR flow) is still sent on a prerendered App Router route in Next.js 16.3.6 — it did not get removed. It now sits alongside two newer headers: x-nextjs-prerender (marks the response as a prerendered/static one) and x-nextjs-stale-time (the Client Cache’s stale window in seconds, 300 here matching the default cacheLife profile). All three showed up identically whether cacheComponents was on or off.
How it maps onto the caching layers
Section titled “How it maps onto the caching layers”Revalidation touches three of the four layers. It drops the matching entries from the Data Cache, and because a route’s rendered output depends on that data, it also marks the affected Full Route Cache entries stale. The next request re-runs the render with fresh data and repopulates both. The browser-side Client Cache is invalidated too: called from a Server Action, revalidateTag / revalidatePath / updateTag purge it immediately; called from a Route Handler, the client picks up the change on its next navigation. Only Request Memoization is untouched — it lives and dies inside a single render.
flowchart LR
A["Server Action: mutate"] --> B["revalidateTag('posts')"]
B --> C["Data Cache: drop tagged entries"]
C --> D["Full Route Cache: mark routes stale"]
D --> E["Next request re-renders with fresh data"]
B --> F["Client Cache: purged when called from a Server Action"]
Mental Model
Section titled “Mental Model”Every revalidation call answers the same question — “which cached entries are now wrong?” — with a different granularity. revalidateTag answers it in terms of what data (every entry carrying that tag, wherever it lives). revalidatePath answers it in terms of what route (everything cached for that path, regardless of tags). Time-based revalidate answers it in terms of how long (a clock, not an event). None of them delete data immediately by default — revalidateTag/revalidatePath mark it stale and refresh in the background; only updateTag (Server Actions only) expires it right away for a read-your-own-write.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
revalidateTag(tag, 'max') | Precise — only entries carrying that exact tag are touched | Requires the fetch to have been tagged in the first place; a forgotten tag is invisible to this call |
revalidatePath('/blog/1') | Works without knowing any tags | Only revalidates that literal path — a sibling page using the same underlying data stays stale until its own path (or the shared tag) is revalidated |
revalidatePath('/', 'layout') | One call, whole app fresh | The biggest hammer available — purges the Client Cache and marks every route for revalidation, which is wasteful for an ordinary single-record mutation |
| Secret-gated Route Handler | Lets an external system (CMS, webhook) trigger revalidation safely | Adds an operational surface: the secret must be provisioned, kept out of NEXT_PUBLIC_, and rotated if it leaks |
Common Misconceptions
Section titled “Common Misconceptions”- “revalidateTag deletes the cached data immediately.” It does not (by design) — it marks entries stale and serves the old value while a background refresh runs, for up to the
cacheLifeprofile passed as the second argument. OnlyupdateTagexpires immediately, and only from a Server Action. - “revalidatePath(‘/blog/1’) also revalidates /blog/2 if they share a fetch tag.” It does not —
revalidatePathonly touches the literal path (or the page/layout pattern given), never other paths that happen to use the same tagged data. That is exactly what tag-based revalidation is for. - “revalidatePath(’/’, ‘layout’) is a safe default to sprinkle after any mutation.” It works, but it is the single largest revalidation call available — it purges the Client Cache and marks the entire app for revalidation. Reach for the narrowest call that is actually correct.
- “A revalidation Route Handler without a secret check is fine because the URL is obscure.” An obscure URL is not authorization — anyone who finds or guesses it can trigger revalidation (or, worse, a denial-of-service via repeated cache purges) with no secret in place.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet revalidates cached data, check for these before trusting it:
- Does
revalidateTagget exactly two arguments? A one-argument call is a TypeScript error in Next.js 16 (revalidateTagrequires acacheLifeprofile as the second argument) — a generated snippet written against older docs or training data commonly omits it. - Is a revalidation Route Handler missing a secret check? Any Route Handler that calls
revalidateTag/revalidatePathneeds to verify a caller-supplied secret against a server-only environment variable before doing anything. - Is
revalidatePath('/', 'layout')used for an ordinary single-record mutation? That is the largest possible blast radius — reach for a tag or a specific path instead unless the change genuinely affects the whole app. - Does the code call
updateTagfrom a Route Handler?updateTagonly works inside Server Actions — a Route Handler needsrevalidateTaginstead.
🐛 Spot the bug
'use server'
import { revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) {await fetch('https://api.example.com/posts', { method: 'POST', body: JSON.stringify({ title: formData.get('title') }),})revalidateTag('posts')}Show the answer
revalidateTag is called with only one argument. Since Next.js 16 it requires a second argument — a cacheLife profile describing how long stale data may still be served while the refresh runs. Proven in the probe: tsc --noEmit on this exact call reports error TS2554: Expected 2 arguments, but got 1. — a real compile error, not a lint warning. The fix is revalidateTag('posts', 'max') (or another named profile), which compiles cleanly.