Incremental Static Regeneration
The Rebuild Problem
SSG has a scaling problem. Your blog has 10,000 posts. You fix a typo in one post. With pure SSG, you rebuild all 10,000 pages. That takes 15 minutes. For one typo.
ISR solves this by regenerating pages individually and in the background, after they've been deployed. You get SSG's CDN speed with the ability to update pages without a full rebuild.
Traditional SSG:
Change 1 post → rebuild ALL 10,000 pages → deploy → 15 minutes
ISR:
Change 1 post → next visitor triggers background regeneration → 200ms
The other 9,999 pages are untouched
The Mental Model
ISR works exactly like a browser cache with max-age and stale-while-revalidate.
Imagine a newspaper vending machine. The machine has today's newspaper (the cached static page). You set a rule: "After 60 seconds, the newspaper might be stale."
When someone opens the machine within 60 seconds, they get the cached copy instantly. When someone opens it after 60 seconds, they still get the cached copy instantly (stale-while-revalidate), but the machine quietly sends someone to the press to print a fresh copy. The next person who opens the machine gets the fresh copy.
The key insight: no visitor ever waits for regeneration. Everyone gets an instant response. The regeneration happens in the background, and the updated page is ready for the next visitor.
Time-Based Revalidation
The simplest form of ISR: set a time window after which the page is considered stale.
// Next.js 15 App Router
// This page revalidates every 60 seconds
export const revalidate = 60
export default async function PricingPage() {
const plans = await fetchPricingPlans()
return (
<div>
<h1>Pricing</h1>
{plans.map(plan => (
<PricingCard key={plan.id} plan={plan} />
))}
</div>
)
}
The lifecycle:
Build time:
→ Next.js renders PricingPage, saves HTML + RSC payload
→ Serves from CDN
Request at t=0s:
→ CDN serves cached page (instant)
→ Page is "fresh" — no regeneration triggered
Request at t=45s:
→ CDN serves cached page (instant)
→ Still within 60s window — no regeneration
Request at t=65s:
→ CDN serves cached (stale) page (instant)
→ Next.js triggers background regeneration
→ Server fetches new data, renders new HTML
→ New page replaces the old one in the cache
Request at t=70s:
→ CDN serves the NEW page (generated at t=65s)
On-Demand Revalidation
Time-based ISR has a problem: if your content changes at t=5 and your revalidation window is 60 seconds, users see stale content for up to 55 seconds. For some use cases, that's unacceptable.
On-demand revalidation lets you trigger regeneration immediately when content changes:
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache'
import { NextRequest } from 'next/server'
export async function POST(request: NextRequest) {
const { tag, path, secret } = await request.json()
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Invalid secret' }, { status: 401 })
}
if (tag) {
revalidateTag(tag) // Invalidate all pages using this cache tag
}
if (path) {
revalidatePath(path) // Invalidate a specific page
}
return Response.json({ revalidated: true })
}
// The page uses cache tags for targeted invalidation
export default async function BlogPost({ params }) {
const post = await fetch(`https://cms.example.com/posts/${params.slug}`, {
next: { tags: [`post-${params.slug}`, 'posts'] }
}).then(r => r.json())
return <article>{post.content}</article>
}
Now your CMS fires a webhook when content is published:
CMS publishes article "hello-world"
→ Webhook hits /api/revalidate with { tag: "post-hello-world" }
→ Next.js marks all pages using that tag as stale
→ Next request for that page triggers regeneration
→ Fresh content available within seconds of publishing
revalidatePath vs revalidateTag
// revalidatePath: invalidate a specific URL
revalidatePath('/blog/hello-world') // Only this page
revalidatePath('/blog') // The blog listing page
revalidatePath('/', 'layout') // Entire site (use sparingly)
// revalidateTag: invalidate all pages that used a specific cache tag
revalidateTag('posts') // All pages that fetched with { tags: ['posts'] }
revalidateTag('post-hello-world') // Pages using this specific tag
revalidateTag is more powerful because one content change can invalidate multiple pages. If a blog post appears on the listing page, the post page, and a "related posts" widget on other pages, tagging all those fetches with post-hello-world invalidates them all with a single call.
How ISR Works Under the Hood
ISR in Next.js uses a stale-while-revalidate caching strategy at the server level:
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
│ Browser │────▶│ CDN Edge │────▶│ Next.js │
│ │ │ │ │ Server │
│ │◀────│ Cached HTML │ │ │
└─────────────┘ └───────────────┘ └──────────────┘
│ ▲
│ If stale: │
└──────────────────────┘
Background regeneration
The cache states:
FRESH (within revalidate window):
→ Serve from cache. No server work.
STALE (past revalidate window, not yet regenerated):
→ Serve stale from cache (user gets instant response)
→ Trigger background regeneration
→ Lock: only one regeneration runs at a time
REGENERATING:
→ Serve stale from cache
→ Server is rendering the new version
→ When done, atomically replace cache entry
MISS (first request for a new path not built at build time):
→ Server renders the page
→ Response sent to user
→ Page cached for future requests
ISR and dynamic route segments
ISR is especially powerful for dynamic routes with generateStaticParams. You can pre-build your most popular pages and let the rest generate on first request:
export async function generateStaticParams() {
// Only pre-build the top 100 most visited posts
const topPosts = await getTopPosts(100)
return topPosts.map(post => ({ slug: post.slug }))
}
export const dynamicParams = true // Allow pages not in generateStaticParams
export const revalidate = 3600 // Revalidate every hourThe top 100 posts are pre-built at deploy time. The other 9,900 posts generate on first visit and then get cached with ISR. This keeps build times fast while still serving thousands of pages statically.
If you set dynamicParams = false, any path not returned by generateStaticParams will 404 instead of generating on demand.
Production Scenario: The Documentation Site
A team maintains developer docs with 2,000 pages. Docs are stored in a headless CMS and updated 5-10 times per day.
The naive approach: SSG with full rebuild on every CMS change. Build takes 8 minutes. With 10 updates per day, that's 80 minutes of builds and each update takes 8 minutes to go live.
The ISR approach:
// app/docs/[...slug]/page.tsx
export const revalidate = 3600 // 1 hour baseline
export async function generateStaticParams() {
// Pre-build only the 50 most viewed pages
const topPages = await getTopDocPages(50)
return topPages.map(page => ({ slug: page.slug.split('/') }))
}
export default async function DocPage({ params }) {
const doc = await fetch(`${CMS_URL}/docs/${params.slug.join('/')}`, {
next: { tags: [`doc-${params.slug.join('-')}`] }
}).then(r => r.json())
return <DocRenderer content={doc} />
}
// app/api/cms-webhook/route.ts
export async function POST(req: NextRequest) {
const { slug, secret } = await req.json()
if (secret !== process.env.CMS_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 })
}
revalidateTag(`doc-${slug.replace(/\//g, '-')}`)
revalidateTag('docs-sidebar') // Also refresh the navigation
return Response.json({ ok: true })
}
Result:
- Build time: 30 seconds (only 50 pages instead of 2,000)
- Content live after CMS publish: under 5 seconds (webhook triggers immediate revalidation)
- Server cost: minimal (most requests served from cache)
- Cold pages: first visitor triggers generation, cached for subsequent visitors
Common Mistakes
| What developers do | What they should do |
|---|---|
| Setting revalidate too low (like 1 second) thinking it makes content real-time revalidate = 1 effectively turns ISR into SSR — the page regenerates on nearly every request, losing all caching benefits. If you need real-time data, use SSR or client-side fetching. | Use on-demand revalidation for instant updates, time-based ISR for periodic freshness |
| Not using cache tags, relying only on revalidatePath for invalidation A single piece of content often appears on multiple pages (listing, detail, related widgets). Without tags, you must know and manually invalidate every affected URL. | Tag data fetches so one content change can invalidate all affected pages via revalidateTag |
| Pre-building all pages in generateStaticParams for large sites Building 100,000 pages at deploy time takes forever. Pre-build the top 100-1000 and let ISR handle the long tail. First visitors to cold pages trigger generation. | Pre-build only the most popular pages, let the rest generate on demand |
| Expecting ISR to work with personalized content or cookies ISR serves the same cached page to every user. If your page depends on cookies, auth state, or user-specific data, the cached page would show the wrong data to other users. | ISR pages are cached and shared across all users — use SSR for personalized content |
Key Rules
- 1ISR combines SSG speed with the ability to update pages after deployment. Pages are served from cache and regenerated in the background.
- 2The stale-while-revalidate pattern means no visitor ever waits for regeneration. Stale pages are served instantly while a fresh version is generated in the background.
- 3Time-based revalidation (export const revalidate = N) regenerates a page at most once every N seconds after a request triggers it.
- 4On-demand revalidation (revalidateTag, revalidatePath) immediately marks pages as stale, triggered by webhooks or API calls when content changes.
- 5Use revalidateTag for surgical invalidation — tag your data fetches and invalidate all pages using a tag with one call.
- 6ISR pages are not personalized. They are cached and shared across all users. Use SSR for content that depends on cookies, auth, or user-specific data.