🔄 Chapter 15: Revalidation & ISR
Keeping cached content fresh — on a timer, on a webhook, or the instant a user saves a change.
📖 Plain English Explanation
Chapter 14 was about storing results. This chapter is about replacing them.
Every cache faces the same question: this entry is old — now what?
Next.js gives two answers.
Time-based: "refresh this every hour." You set a cacheLife and the framework handles it. No code runs when content changes; the cache just ages out. Good for anything with a natural rhythm — a news feed, a weather widget, a leaderboard.
On-demand: "refresh this now, because something happened." You tag your cached data, and when a mutation occurs you invalidate that tag. Good for anything edited by a human — a blog post, a product listing, a user profile.
Most real apps use both: a long cacheLife so nothing expires needlessly, plus a tag that gets invalidated the moment someone hits Save.
The subtlety Next.js 16 added is a distinction inside "on-demand": do you want the user who made the change to see it immediately, or is it fine for everyone (including them) to see the old version for a few seconds while the new one builds? Those are different functions now, and picking the wrong one is a real bug.
⏲️ Time-based revalidation
Set a lifetime and walk away:
// lib/data.ts
import { cacheLife } from 'next/cache'
export async function getHeadlines() {
'use cache'
cacheLife('minutes') // revalidate: 1 minute
return db.article.findMany({ orderBy: { publishedAt: 'desc' }, take: 10 })
}
// lib/data.js
import { cacheLife } from 'next/cache'
export async function getHeadlines() {
'use cache'
cacheLife('minutes')
return db.article.findMany({ orderBy: { publishedAt: 'desc' }, take: 10 })
}
How stale-while-revalidate actually behaves
This is the mechanic worth understanding properly:
cacheLife('hours') → revalidate: 1h, expire: 1d
t=0min request → MISS → render, cache, serve (slow)
t=30min request → HIT → serve from cache (fast)
t=61min request → STALE → serve OLD immediately (fast)
+ rebuild in background
t=62min request → HIT → serve the NEW version (fast)
...no traffic for a full day...
t=25h request → EXPIRED → wait for a fresh render (slow)
The key insight: after revalidate, users still get an instant response. They get the stale copy while the fresh one builds behind them. Only after expire — meaning nobody visited for that long — does someone actually wait.
That's why revalidate and expire are separate numbers. revalidate is "how fresh do I want this?" and expire is "how stale is too stale to serve at all?"
The stale window is different
stale is the browser's copy, not the server's. During that window the client router renders from memory without a network request at all — that's why client-side navigation back to a page you just visited is instant.
Note the floor: the client enforces a minimum 30-second stale time regardless of your config, so a prefetched link can't expire before the user gets a chance to click it.
🎯 On-demand revalidation
Time-based caching wastes work: you regenerate on a schedule whether anything changed or not, and you serve stale content even when nothing changed.
On-demand flips it. Cache for a long time, and invalidate exactly when the data actually changes.
Step 1: tag it
// lib/posts.ts
import { cacheLife, cacheTag } from 'next/cache'
export async function getPost(slug: string) {
'use cache'
cacheLife('max') // 30-day revalidate — we don't rely on time
cacheTag(`post-${slug}`) // specific
cacheTag('posts') // broad
return db.post.findUnique({ where: { slug } })
}
export async function getPostList() {
'use cache'
cacheLife('max')
cacheTag('posts') // shares the broad tag
return db.post.findMany({ where: { published: true } })
}
// lib/posts.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 } })
}
export async function getPostList() {
'use cache'
cacheLife('max')
cacheTag('posts')
return db.post.findMany({ where: { published: true } })
}
Tagging at two levels is deliberate: editing one post can invalidate just that post and the list it appears in.
Step 2: invalidate on write
// app/actions.ts
'use server'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
export async function editPost(slug: string, data: PostInput) {
await db.post.update({ where: { slug }, data })
updateTag(`post-${slug}`) // this post
updateTag('posts') // and the list
}
🔀 The four invalidation functions
| Function | Callable from | Behavior | Use when |
|---|---|---|---|
updateTag(tag) | Server Actions only | Expires immediately and refreshes in the same request | The user must see their own change |
revalidateTag(tag, profile) | Server Actions and Route Handlers | Stale-while-revalidate | A short delay is acceptable |
revalidatePath(path) | Server Actions and Route Handlers | Invalidates everything for a route | You don't know the tags |
refresh() | Server Actions | Re-renders the current route on the client | Redraw visible UI after an action |
updateTag — read-your-writes
⚠️ New in Next.js 16
The scenario this exists for: a user edits their profile, hits Save, and sees their old name. Technically the cache is revalidating in the background. From the user's point of view, the app is broken.
// app/actions.ts
'use server'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import { verifySession } from '@/lib/dal'
export async function updateProfile(formData: FormData) {
const session = await verifySession()
if (!session) return { error: 'Not signed in' }
await db.user.update({
where: { id: session.userId },
data: { name: formData.get('name') as string },
})
updateTag(`user-${session.userId}`) // expire AND refresh, now
redirect('/profile')
}
// app/actions.js
'use server'
import { updateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import { verifySession } from '@/lib/dal'
export async function updateProfile(formData) {
const session = await verifySession()
if (!session) return { error: 'Not signed in' }
await db.user.update({
where: { id: session.userId },
data: { name: formData.get('name') },
})
updateTag(`user-${session.userId}`)
redirect('/profile')
}
updateTag is Server Actions only, because the guarantee it provides only makes sense in the context of a request that just performed a mutation.
revalidateTag — stale-while-revalidate
⚠️ Changed in Next.js 16
revalidateTagnow requires a second argument — acacheLifeprofile controlling how long stale content may be served while the fresh version builds. The one-argument form is deprecated and errors in TypeScript.ts// ❌ Next.js 15 revalidateTag('posts') // ✅ Next.js 16 revalidateTag('posts', 'max')
// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function publishArticle(id: string) {
await db.article.update({ where: { id }, data: { published: true } })
// Readers may see the old version for a moment. That's fine here.
revalidateTag(`article-${id}`, 'max')
revalidateTag('articles', 'max')
}
The second argument sets the stale window. 'max' gives the longest one, meaning readers essentially never block. Once that window passes, requests wait for fresh content.
Unlike updateTag, this also works in Route Handlers — which is what makes CMS webhooks possible:
// app/api/revalidate/route.ts
import { revalidateTag } from 'next/cache'
export async function POST(request: Request) {
const secret = request.headers.get('x-webhook-secret')
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const { type, slug } = await request.json()
if (type === 'post') {
revalidateTag(`post-${slug}`, 'max')
revalidateTag('posts', 'max')
}
return Response.json({ revalidated: true })
}
// app/api/revalidate/route.js
import { revalidateTag } from 'next/cache'
export async function POST(request) {
const secret = request.headers.get('x-webhook-secret')
if (secret !== process.env.REVALIDATE_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
const { type, slug } = await request.json()
if (type === 'post') {
revalidateTag(`post-${slug}`, 'max')
revalidateTag('posts', 'max')
}
return Response.json({ revalidated: true })
}
Point your CMS at this URL and your site updates the instant an editor publishes — with no time-based revalidation at all. This is the single best caching setup for content sites: cacheLife('max') plus a webhook.
Always require a secret. Otherwise anyone can force-invalidate your entire cache repeatedly, which is a denial-of-service with extra steps.
revalidatePath — the blunt instrument
'use server'
import { revalidatePath } from 'next/cache'
export async function deleteComment(id: string) {
const comment = await db.comment.delete({ where: { id } })
revalidatePath(`/posts/${comment.postId}`)
}
Invalidates everything cached for that route. Useful when you don't know which tags are involved, but blunt — it throws away cached data that didn't change.
Prefer tags. Reach for revalidatePath when tagging would be more trouble than it's worth.
refresh — redraw the current screen
⚠️ New in Next.js 16
Sometimes nothing is cached; the UI is just showing data that's now wrong.
// app/actions.ts
'use server'
import { refresh } from 'next/cache'
export async function markNotificationRead(id: string) {
await db.notification.update({ where: { id }, data: { read: true } })
refresh() // the unread badge in the header re-renders
}
// app/actions.js
'use server'
import { refresh } from 'next/cache'
export async function markNotificationRead(id) {
await db.notification.update({ where: { id }, data: { read: true } })
refresh()
}
refresh() re-runs the Server Components for the current route and merges the result. Client state and scroll position survive. It's the Server Action equivalent of router.refresh().
Choosing between them
Did the user just make this change and need to see it?
├── Yes → updateTag(tag)
└── No → Is it tagged?
├── Yes → revalidateTag(tag, 'max')
└── No → revalidatePath(path)
Nothing is cached, the screen is just stale?
→ refresh()
One more note: calling any of these from a Server Action clears the entire client cache immediately, bypassing the stale window. So the user who triggered the mutation always gets fresh data on their next navigation, regardless of which function you used.
🏗️ Incremental Static Regeneration
ISR is the name for the combination you've now seen the pieces of: pre-render pages at build time, serve them from a CDN, and regenerate them in the background as they age or get invalidated.
The classic setup — a blog with thousands of posts:
// app/blog/[slug]/page.tsx
import { cacheLife, cacheTag } from 'next/cache'
import { notFound } from 'next/navigation'
// 1. Pre-render the popular posts at build time
export async function generateStaticParams() {
const posts = await db.post.findMany({
where: { published: true },
orderBy: { views: 'desc' },
take: 100,
select: { slug: true },
})
return posts.map((p) => ({ slug: p.slug }))
}
// 2. Cache the data with a long life and a tag
async function getPost(slug: string) {
'use cache'
cacheLife('max')
cacheTag(`post-${slug}`)
return db.post.findUnique({ where: { slug } })
}
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
const post = await getPost(slug)
if (!post) notFound()
return <article>{post.body}</article>
}
// app/blog/[slug]/page.js
import { cacheLife, cacheTag } from 'next/cache'
import { notFound } from 'next/navigation'
export async function generateStaticParams() {
const posts = await db.post.findMany({
where: { published: true },
orderBy: { views: 'desc' },
take: 100,
select: { slug: true },
})
return posts.map((p) => ({ slug: p.slug }))
}
async function getPost(slug) {
'use cache'
cacheLife('max')
cacheTag(`post-${slug}`)
return db.post.findUnique({ where: { slug } })
}
export default async function Page(props) {
const { slug } = await props.params
const post = await getPost(slug)
if (!post) notFound()
return <article>{post.body}</article>
}
// app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function publishEdit(slug: string, data: PostInput) {
await db.post.update({ where: { slug }, data })
revalidateTag(`post-${slug}`, 'max')
}
What this gives you:
- The top 100 posts are static HTML at build time — served from a CDN, zero server work
- Post 101 renders on the first request and is cached for the next visitor
- An edit invalidates exactly that one post
- Build times stay short regardless of how many posts exist
ISR with Cache Components: the App Shell
With cacheComponents enabled there's an extra behavior worth knowing.
When a route has dynamic params that aren't in generateStaticParams, Next.js doesn't make the visitor wait for a full render. It serves the App Shell — the reusable, URL-independent static shell, with the param-specific parts still behind their Suspense fallbacks — instantly, then fills in the concrete content in the background and caches it for the next visitor.
So the long tail isn't slow; it's incrementally built. That's what puts the "incremental" in ISR.
🧪 Verifying it works
Revalidation is impossible to test in next dev — caching behaves differently there. Always check against a production build:
npm run build && npm run start
Turn on cache logging:
NEXT_PRIVATE_DEBUG_CACHE=1 npm run start
And watch the response headers:
curl -I http://localhost:3000/blog/hello
# x-nextjs-cache: HIT | MISS | STALE
# x-nextjs-stale-time: 300
A quick manual test:
- Load the page →
MISS - Reload →
HIT - Wait past
revalidate→STALE, response still fast - Reload again →
HIT, now with new content - Trigger your Server Action → next load is
MISSwith new content
⚠️ Common Pitfalls
1. revalidateTag with one argument
// ❌ TypeScript error in Next.js 16
revalidateTag('posts')
// ✅
revalidateTag('posts', 'max')
2. revalidateTag when the user needs to see their own change
// ❌ user saves, sees the old value, files a bug
export async function updateProfile(data) {
await db.user.update(...)
revalidateTag('user', 'max')
}
// ✅
export async function updateProfile(data) {
await db.user.update(...)
updateTag('user')
}
3. updateTag in a Route Handler
It's Server Actions only. Use revalidateTag in handlers.
4. Tag name mismatch
cacheTag('posts')
// ...elsewhere
updateTag('post') // ❌ silently does nothing
There's no error for invalidating a tag nobody registered. Define tags in one place:
// lib/tags.ts
export const tags = {
posts: 'posts',
post: (slug: string) => `post-${slug}`,
user: (id: string) => `user-${id}`,
} as const
5. Forgetting to revalidate at all
The database is updated; the page shows old data. Every mutation needs a matching invalidation.
6. Over-invalidating
// ❌ nukes the cache for one comment
export async function addComment(postId: string, body: string) {
await db.comment.create({ data: { postId, body } })
revalidatePath('/', 'layout')
}
// ✅
updateTag(`post-${postId}-comments`)
7. An unauthenticated revalidation endpoint
// ❌ anyone can force a cache stampede
export async function POST(request: Request) {
revalidateTag('posts', 'max')
return Response.json({ ok: true })
}
Always check a shared secret or signature.
8. Testing in dev mode
Caching behaves differently in next dev. Test with next build && next start.
9. Assuming ISR persists across deploys
use cache entries include the build ID, so a deploy clears them. Prerendered HTML is regenerated at build time. If you need cross-deploy persistence, that's unstable_cache or the fetch cache.
🎯 When & Why to Use
Time-based (cacheLife) when:
✅ Content changes on a rhythm you can predict
✅ There's no write event to hook into (external API, scraped data)
✅ Approximate freshness is fine
On-demand (tags) when:
✅ Content changes because a human did something
✅ You control the write path, or your CMS can call a webhook
✅ You want long cache lifetimes without stale content
Both when:
✅ Most real apps — cacheLife('max') + cacheTag, invalidated on write
Time-based becomes a safety net, not the mechanism
Neither when:
❌ Data must be correct to the millisecond → don't cache, stream it
🏋️ Mini Practice Problems
Problem 1: Pick the function
updateTag, revalidateTag, revalidatePath, or refresh?
- A. A user renames their workspace and must see the new name in the sidebar
- B. A CMS webhook fires when an article is published
- C. An admin bulk-imports 500 products
- D. A user dismisses a notification; the header badge should decrement
- E. A comment is deleted from a post page with no tags set up
Problem 2: Fix the code
'use server'
import { revalidateTag } from 'next/cache'
export async function saveDraft(id: string, content: string) {
await db.draft.update({ where: { id }, data: { content } })
revalidateTag('draft')
}
Three problems. The author reports "I save my draft and it still shows the old text." Explain and fix.
Problem 3: Design the strategy
An e-commerce product page shows:
- Product name, description, images — edited by admins, rarely
- Price — changes via a pricing service several times a day
- Stock count — must be accurate within seconds
- Reviews — new ones appear throughout the day
- "Recently viewed" — per user
For each: cache or not? Which cacheLife? Which tags? Which invalidation function on write?
Problem 4: Build the webhook
Write a /api/revalidate handler that:
- Verifies an HMAC signature, not just a shared secret
- Accepts
{ type, id }forpost,product, orcategory - Invalidates the specific item and its list
- Returns 401 on a bad signature, 400 on an unknown type
- Logs what it invalidated
💼 Interview Notes
Common Questions
Q: What is ISR? Incremental Static Regeneration: pages are prerendered as static HTML, served from a CDN, and regenerated in the background when they age past their revalidate window or are invalidated by tag. You get static performance with dynamic content and no full rebuild.
Q: Explain stale-while-revalidate.
After the revalidate window, the next request is still served the cached copy instantly while a fresh version is generated in the background. Users never wait for the regeneration. Only after expire — meaning no traffic for that long — does a request block on a fresh render.
Q: What's the difference between updateTag and revalidateTag?
updateTag expires and refreshes within the same request, so the user who made the change sees it immediately — read-your-writes. It's Server Actions only. revalidateTag uses stale-while-revalidate, so readers may briefly see old content, and it works in Route Handlers too, which is what makes webhooks possible.
Q: Why does revalidateTag need a second argument now?
It sets how long stale content may be served while fresh content generates. Making it explicit stops the framework from guessing, and forces you to think about the acceptable staleness window.
Q: How do you make a CMS-driven site update instantly without time-based revalidation?
Cache with cacheLife('max') and cacheTag, then expose a webhook Route Handler that calls revalidateTag(tag, 'max'). Point the CMS at it. Nothing revalidates until content actually changes.
Q: How do you handle a route with a million possible params?
generateStaticParams for the popular subset, dynamicParams: true (the default) for the rest. With Cache Components, unlisted params get the App Shell served instantly and the concrete page filled in behind it and cached.
Q: Why can't you test revalidation in next dev?
Development disables most caching so you always see fresh code and data. Cache behavior only reflects production under next build && next start.
🏢 Asked at Companies
- Vercel: "Ten million product pages, prices updating hourly, stock in real time. Design the caching and revalidation."
- Shopify: "A merchant edits a product and doesn't see the change. Walk through the diagnosis."
- The New York Times: "Articles are published constantly and must appear immediately, but you can't rebuild the site. How?"
- Stripe: "Why is an unauthenticated revalidation endpoint dangerous?"
📊 Visual Memory Aid
STALE-WHILE-REVALIDATE TIMELINE
cacheLife('hours') revalidate: 1h expire: 1d
0m ────────── 60m ──────────────────────── 24h ──────►
│ FRESH │ STALE │ EXPIRED
│ serve cache │ serve cache + rebuild │ block &
│ (fast) │ (still fast) │ render
│ │ │ (slow)
THE FOUR FUNCTIONS
┌──────────────────┬────────────────┬──────────────────┐
│ │ Callable from │ Behavior │
├──────────────────┼────────────────┼──────────────────┤
│ updateTag() │ Actions only │ immediate │
│ revalidateTag() │ Actions+Routes │ stale-while-reval│
│ revalidatePath() │ Actions+Routes │ whole route │
│ refresh() │ Actions │ re-render client │
└──────────────────┴────────────────┴──────────────────┘
THE DECISION
user made the change and must see it?
└─► updateTag(tag)
webhook / background / others will read it?
└─► revalidateTag(tag, 'max')
don't know the tags?
└─► revalidatePath(path)
nothing cached, just stale UI?
└─► refresh()
THE IDEAL CONTENT SETUP
cacheLife('max') + cacheTag('post-x')
│
└──► never expires by time
↑
CMS webhook ─┘ revalidateTag('post-x', 'max')
Zero wasted regeneration. Instant updates.
🎯 Key Takeaways
- Time-based revalidation is a schedule; on-demand is an event. Real apps combine both — a long
cacheLifewith tags invalidated on write. updateTagfor read-your-writes,revalidateTagfor everything else. Getting this backwards produces the classic "I saved but it still shows the old value" bug.revalidateTag(tag, profile)needs its second argument in Next.js 16. It sets how long stale content may be served during the background rebuild.- Stale-while-revalidate means nobody waits. Between
revalidateandexpire, requests get the cached copy instantly while a fresh one builds. cacheLife('max')plus a CMS webhook is the best setup for content sites — nothing regenerates unless content actually changed, and updates are instant. Just authenticate the endpoint.
Next Chapter: Cache Components & Partial Prerendering →
Practice: Build a blog with generateStaticParams for the top posts, cacheLife('max') plus tags, an authenticated /api/revalidate webhook, and an edit action using updateTag. Then run next build && next start and watch x-nextjs-cache go MISS → HIT → STALE → HIT.