Dev Logs
/Next.js/ Chapter 15: Revalidation & ISR
Chapters
  • 01Chapter 1: Introduction & Setup
  • 02Chapter 2: Project Structure & Configuration
  • 03Chapter 3: Layouts & Pages
  • 04Chapter 4: Linking & Navigation
  • 05Chapter 5: Dynamic Routes & Params
  • 06Chapter 6: Route Groups & Organization
  • 07Chapter 7: Parallel & Intercepting Routes
  • 08Chapter 8: Loading, Suspense & Streaming
  • 09Chapter 9: Error Handling
  • 10Chapter 10: Server & Client Components
  • 11Chapter 11: Data Fetching
  • 12Chapter 12: Server Actions & Mutations
  • 13Chapter 13: Route Handlers
  • 14Chapter 14: Caching & use cache
  • 15Chapter 15: Revalidation & ISR
    • Plain English Explanation
    • Time-based revalidation
    • How stale-while-revalidate actually behaves
    • The stale window is different
    • On-demand revalidation
    • Step 1: tag it
    • Step 2: invalidate on write
    • The four invalidation functions
    • updateTag — read-your-writes
    • revalidateTag — stale-while-revalidate
    • revalidatePath — the blunt instrument
    • refresh — redraw the current screen
    • Choosing between them
    • Incremental Static Regeneration
    • ISR with Cache Components: the App Shell
    • Verifying it works
    • Common Pitfalls
    • . revalidateTag with one argument
    • . revalidateTag when the user needs to see their own change
    • . updateTag in a Route Handler
    • . Tag name mismatch
    • . Forgetting to revalidate at all
    • . Over-invalidating
    • . An unauthenticated revalidation endpoint
    • . Testing in dev mode
    • . Assuming ISR persists across deploys
    • When & Why to Use
    • Mini Practice Problems
    • Problem 1: Pick the function
    • Problem 2: Fix the code
    • Problem 3: Design the strategy
    • Problem 4: Build the webhook
    • Interview Notes
    • Common Questions
    • Asked at Companies
    • Visual Memory Aid
    • Key Takeaways
  • 16Chapter 16: Cache Components & Partial Prerendering
  • 17Chapter 17: Proxy (formerly Middleware)
  • 18Chapter 18: Authentication & Authorization
  • 19Chapter 19: Metadata, SEO & OG Images
  • 20Chapter 20: Images & Fonts
  • 21Chapter 21: Styling
  • 22Chapter 22: Performance Optimization
  • 23Chapter 23: Testing
  • 24Chapter 24: Deployment & Self-Hosting
  • 25Chapter 25: Upgrading to Next.js 16
  • 26Chapter 26: Capstone Project
  • 27Chapter 27: React Performance Profiling
All chapters

🔄 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:

ts
// 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 })
}
js
// 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

ts
// 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 } })
}
js
// 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

ts
// 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

FunctionCallable fromBehaviorUse when
updateTag(tag)Server Actions onlyExpires immediately and refreshes in the same requestThe user must see their own change
revalidateTag(tag, profile)Server Actions and Route HandlersStale-while-revalidateA short delay is acceptable
revalidatePath(path)Server Actions and Route HandlersInvalidates everything for a routeYou don't know the tags
refresh()Server ActionsRe-renders the current route on the clientRedraw 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.

ts
// 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')
}
js
// 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

revalidateTag now requires a second argument — a cacheLife profile 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')
ts
// 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:

ts
// 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 })
}
js
// 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

ts
'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.

ts
// 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
}
js
// 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:

tsx
// 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>
}
jsx
// 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>
}
ts
// 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:

bash
npm run build && npm run start

Turn on cache logging:

bash
NEXT_PRIVATE_DEBUG_CACHE=1 npm run start

And watch the response headers:

bash
curl -I http://localhost:3000/blog/hello
# x-nextjs-cache: HIT | MISS | STALE
# x-nextjs-stale-time: 300

A quick manual test:

  1. Load the page → MISS
  2. Reload → HIT
  3. Wait past revalidate → STALE, response still fast
  4. Reload again → HIT, now with new content
  5. Trigger your Server Action → next load is MISS with new content

⚠️ Common Pitfalls

1. revalidateTag with one argument

ts
// ❌ TypeScript error in Next.js 16
revalidateTag('posts')

// ✅
revalidateTag('posts', 'max')

2. revalidateTag when the user needs to see their own change

ts
// ❌ 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

ts
cacheTag('posts')
// ...elsewhere
updateTag('post')      // ❌ silently does nothing

There's no error for invalidating a tag nobody registered. Define tags in one place:

ts
// 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

ts
// ❌ 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

ts
// ❌ 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

ts
'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 } for post, product, or category
  • 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

  1. Time-based revalidation is a schedule; on-demand is an event. Real apps combine both — a long cacheLife with tags invalidated on write.
  2. updateTag for read-your-writes, revalidateTag for everything else. Getting this backwards produces the classic "I saved but it still shows the old value" bug.
  3. revalidateTag(tag, profile) needs its second argument in Next.js 16. It sets how long stale content may be served during the background rebuild.
  4. Stale-while-revalidate means nobody waits. Between revalidate and expire, requests get the cached copy instantly while a fresh one builds.
  5. 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.


PreviousChapter 14: Caching & use cacheNextChapter 16: Cache Components & Partial Prerendering

Open source, free forever. Built by iammhador.

Contribute on GitHub