React 111 ⚛️ Incremental Static Regeneration (ISR)
Incremental Static Regeneration is the bridge between the two extremes of web rendering. Pure static generation produces fast pages but freezes content at build time. Pure server-side rendering produces fresh content but pays a computation cost on every request. ISR takes a third path: it serves static HTML from a cache, and when that cache becomes stale, it regenerates the page in the background while still serving the old version to the visitor who triggered the regeneration . The result is a page that is almost always static and almost always fresh, with neither property absolute.
This chapter covers ISR as it exists in the React ecosystem, specifically through Next.js, which introduced the pattern and remains its primary implementation. It explains the stale-while-revalidate mechanics that make ISR work, the two modes of triggering regeneration—time-based and on-demand—and the cache invalidation strategies that determine which pages get regenerated. The final sections address the caveats: the runtime requirements, the platform limitations, and the cases where ISR is the wrong tool for the problem.
Key point: ISR does not make a page dynamic. It makes a static page replaceable. The visitor who triggers regeneration still receives the stale version. The fresh version is for the next visitor.
Why ISR exists
The build-time bottleneck. A site with ten thousand product pages cannot regenerate every page on every deploy. A full static build of that size takes minutes or hours, and the vast majority of those pages did not change. ISR allows the build to generate a subset—popular pages, recently updated pages—and defer the rest to runtime . The build time scales with the number of pages that actually need to exist at launch, not with the total size of the catalog.
The staleness problem. Pure static generation has no mechanism for updating a page after deployment. If a product price changes, the static page shows the old price until the next build. ISR solves this by giving every static page a shelf life. After the revalidate interval elapses, the next request triggers a background regeneration . The page is still static for most visitors, but it is not permanently frozen.
The server cost of full SSR. Rendering every request on the server means paying the computation cost every time. For content that changes hourly or daily, that cost is wasted on the thousands of requests that fall between changes. ISR pays the computation cost only when regeneration is needed, which is a small fraction of total requests . The server load approaches static hosting while the freshness approaches server rendering.
The cache invalidation control. Time-based revalidation is simple but imprecise: it regenerates whether the content changed or not. On-demand revalidation gives the application control over when regeneration happens. A CMS webhook, an admin action, or a data mutation can trigger revalidatePath or revalidateTag to invalidate specific cache entries immediately . This is the pattern for content that changes unpredictably: time-based for the baseline, on-demand for the events that matter.
The infrastructure requirement. ISR is not a build-time feature. It requires a running server that can store the cache, serve stale pages, and execute background regenerations. A purely static export—HTML files on a CDN with no server—cannot support ISR . This is the primary constraint: ISR works on platforms that host a Next.js server, not on platforms that host only static files.
a. The stale-while-revalidate mechanics
The core of ISR is a cache with a time-to-live. The page is generated at build time and stored. When a request arrives, the server checks the age of the cached version against the revalidate interval. If the cache is fresh, the server returns it immediately. If the cache is stale, the server still returns it immediately, but also starts a background regeneration. The next request, after regeneration completes, receives the new version .
// pages/blog/[id].js — Pages Router example
// The revalidate value is in seconds.
export async function getStaticProps({ params }) {
const res = await fetch(`https://api.example.com/posts/${params.id}`);
const post = await res.json();
return {
props: { post },
// Regenerate at most once every 60 seconds.
// The visitor who triggers regeneration still sees the old page.
revalidate: 60,
};
}
export async function getStaticPaths() {
// Pre-render only the most recent posts at build time.
const posts = await fetch('https://api.example.com/posts?limit=10').then(r => r.json());
return {
paths: posts.map((post) => ({ params: { id: post.id } })),
// Pages not in the list are generated on first request.
fallback: 'blocking',
};
}
The sequence for a stale request is precise. At t=0, the page is generated. At t=30, a visitor requests the page and receives the cached version. At t=61, another visitor requests the page. The server returns the cached version from t=0, and simultaneously begins regenerating. At t=62, regeneration completes and the cache is replaced. At t=63, the next visitor receives the version generated at t=61 . The visitor at t=61 experienced no delay, but their page was 61 seconds old.
The fallback value in getStaticPaths determines what happens for paths not generated at build time. fallback: 'blocking' means the first request for an ungenerated path waits for the page to render, then caches it. fallback: true means the first request serves a loading state and the page renders in the background. fallback: false means ungenerated paths return a 404 . The choice depends on whether the catalog of paths is known at build time and whether a loading state is acceptable.
b. Time-based and on-demand revalidation
Time-based revalidation is the default ISR mode. It requires only the revalidate value and no additional infrastructure. The interval should match the content’s change frequency: a blog post might revalidate every hour, a product catalog every minute, a news feed every few seconds . Setting the interval too low wastes server resources on regenerations that produce identical content. Setting it too high serves stale content longer than necessary.
On-demand revalidation gives the application explicit control. In the Pages Router, an API route calls res.revalidate(path) to regenerate a specific path immediately . In the App Router, revalidatePath and revalidateTag invalidate cache entries from Server Actions or Route Handlers .
// app/actions.ts — App Router on-demand revalidation
'use server';
import { revalidatePath, revalidateTag } from 'next/cache';
export async function updateProduct(id: string, data: ProductData) {
// Mutate the database first.
await db.products.update({ where: { id }, data });
// Invalidate by path: the next request to /products/[id] regenerates.
revalidatePath(`/products/${id}`);
// Or invalidate by tag: all fetches tagged 'products' regenerate.
// revalidateTag('products', 'max');
}
The distinction between path-based and tag-based revalidation is granularity. revalidatePath invalidates every cache entry for a route, regardless of what data it used. revalidateTag invalidates every cache entry that used data tagged with a specific string . Tags are more precise: if a product page fetches both product data and review data, tagging the product fetch allows the review cache to survive a product update. The recommended pattern is to prefer tags when possible, because path invalidation can over-invalidate and cause unnecessary regeneration .
The revalidateTag function uses stale-while-revalidate semantics by default. The second argument, 'max', extends the stale window: stale content is served immediately while fresh content loads in the background. Without this argument, the behavior depends on the framework version and configuration . For content where a slight delay is acceptable—blog posts, catalogs—this is the right choice. For read-your-own-writes scenarios, where the user who made a change must see it immediately, the stale window must be zero or the page must be dynamic.
c. The platform and runtime constraints
ISR requires a persistent cache and a server process. This excludes purely static hosting. A site deployed as HTML files to a CDN has no mechanism to regenerate pages at runtime, no place to store the stale-while-revalidate state, and no server to execute the background regeneration . The Next.js documentation lists this as a caveat: ISR is not supported when creating a static export .
Platforms differ in their ISR support. Vercel, the company behind Next.js, persists the ISR cache globally and handles rollbacks automatically . Self-hosted Next.js deployments can configure a custom cache location for durable storage or shared caching across multiple instances . Other platforms may support time-based ISR but not on-demand invalidation. Azure Static Web Apps, for example, supports time-based revalidation but does not propagate revalidateTag or revalidatePath invalidations to the CDN, so on-demand changes do not appear until the TTL expires .
The App Router introduces a new model with Cache Components and Partial Prefetching. In this model, the “App Shell” is a static shell that can be served instantly for paths not generated at build time, and the URL-specific content is rendered and stored in the ISR cache. The distinction matters for how params is accessed: if params is awaited inside a <Suspense> boundary without Partial Prefetching enabled, the fallback shell is served repeatedly instead of creating a URL-specific ISR entry . This is a subtle configuration detail that changes whether unlisted paths benefit from ISR at all.
Complete Example Session
// ============================================
// PART 1: THE PAGE WITH TIME-BASED ISR
// ============================================
// A product page that regenerates every 60 seconds.
// The visitor who triggers regeneration sees the stale version.
// app/products/[id]/page.tsx
export const revalidate = 60; // seconds
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await fetch(`https://api.example.com/products/${params.id}`).then(r => r.json());
return (
<main>
<h1>{product.name}</h1>
<p>${product.price}</p>
</main>
);
}
// ============================================
// PART 2: THE STATIC PARAMS
// ============================================
// Pre-render the most popular products at build time.
// Others generate on first request.
export async function generateStaticParams() {
const popular = await fetch('https://api.example.com/products/popular').then(r => r.json());
return popular.map((p: { id: string }) => ({ id: p.id }));
}
// ============================================
// PART 3: THE ON-DEMAND ACTION
// ============================================
// A Server Action that invalidates the cache after a price change.
'use server';
import { revalidatePath } from 'next/cache';
export async function updatePrice(id: string, price: number) {
await db.products.update({ where: { id }, data: { price } });
// The next request to this path regenerates.
revalidatePath(`/products/${id}`);
}
// ============================================
// PART 4: THE TAG-BASED ALTERNATIVE
// ============================================
// Tag the fetch so revalidation can be more precise.
// In the page:
const product = await fetch(`https://api.example.com/products/${params.id}`, {
next: { tags: [`product-${params.id}`] },
}).then(r => r.json());
// In the action:
import { revalidateTag } from 'next/cache';
export async function updateProductTag(id: string) {
await db.products.update({ where: { id }, data: { updated: true } });
revalidateTag(`product-${id}`, 'max');
}
// ============================================
// PART 5: THE FALLBACK BEHAVIOR
// ============================================
// Pages Router: fallback controls ungenerated paths.
// pages/products/[id].tsx
export async function getStaticPaths() {
return {
paths: [{ params: { id: '1' } }],
fallback: 'blocking', // first request waits for generation
};
}
// ============================================
// PART 6: THE CACHE DEBUGGING
// ============================================
// Verify ISR behavior in production mode.
// .env
NEXT_PRIVATE_DEBUG_CACHE=1
// Run: next build && next start
// The server logs cache hits and misses for each request.
// ============================================
// PART 7: THE STALE SEQUENCE
// ============================================
// t=0: build generates page, cache stores it
// t=30: request → cached version (fresh)
// t=61: request → cached version (stale), regeneration starts
// t=62: regeneration completes, cache replaced
// t=63: request → new version
// ============================================
// PART 8: THE ERROR CASE
// ============================================
// If regeneration fails, the old cache remains.
// The next request retries.
// In getStaticProps or the page component:
// throw new Error('fetch failed') prevents cache update.
// The stale page continues to serve.
// ============================================
// PART 9: THE DYNAMIC ESCAPE HATCH
// ============================================
// If the content must be fresh on every request,
// opt out of ISR entirely.
export const dynamic = 'force-dynamic';
// ============================================
// PART 10: THE FULL PICTURE
// ============================================
// ISR trades a small amount of staleness for a large
// reduction in server cost. The revalidate interval is
// the dial. On-demand revalidation is the override.
The ten parts build a complete ISR implementation: the page, the static params, the two revalidation modes, the fallback behavior, the debugging setup, the stale sequence, the error handling, and the dynamic escape hatch. The pattern is consistent: generate at build or first request, serve from cache, regenerate in the background when stale, and invalidate explicitly when the application knows the content changed.
Quick Reference
ISR Modes
| Mode | Trigger | API | Use Case |
|---|---|---|---|
| Time-based | revalidate interval | export const revalidate = 60 | Predictable change frequency |
| On-demand path | Explicit call | revalidatePath('/path') | CMS webhook, admin action |
| On-demand tag | Explicit call | revalidateTag('tag', 'max') | Granular data invalidation |
| Pages Router | API route | res.revalidate('/path') | Legacy on-demand |
Fallback Behavior (Pages Router)
| Value | Ungenerated Path | Loading State |
|---|---|---|
'blocking' | Wait for render | None |
true | Render in background | router.isFallback |
false | 404 | None |
Revalidation Granularity
| Method | Invalidates | Precision |
|---|---|---|
revalidatePath | All cache for a route | Coarse |
revalidateTag | Cache entries with a tag | Fine |
revalidateTag(tag, 'max') | Tagged entries, extended stale window | Fine |
Platform Support
| Platform | Time-based | On-demand | Notes |
|---|---|---|---|
| Vercel | Yes | Yes | Global cache, rollback |
| Self-hosted Node | Yes | Yes | Custom cache location |
| Static export | No | No | No server runtime |
| Azure SWA | Yes | No | CDN does not propagate invalidation |
Best Practices
✅ Do This:
// Set a revalidate interval that matches change frequency.
export const revalidate = 3600; // hourly blog // ✅
// Prefer tags for granular invalidation.
revalidateTag(`product-${id}`, 'max'); // ✅
// Pre-render popular paths at build time.
generateStaticParams() { return popular.map(...); } // ✅
// Use fallback: 'blocking' for SEO-sensitive pages.
return { fallback: 'blocking' }; // ✅
// Throw on regeneration failure to preserve old cache.
throw new Error('fetch failed'); // ✅
// Debug with NEXT_PRIVATE_DEBUG_CACHE in production mode.
// next build && next start // ✅
❌ Don’t Do This:
// Don't set revalidate to a very low value for static content.
export const revalidate = 1; // regenerates constantly // ❌
// Don't expect on-demand revalidation on a static export.
// No server runtime exists to process it. // ❌
// Don't use ISR for real-time data.
export const revalidate = 5; // stock prices need dynamic // ❌
// Don't rely on revalidatePath when a tag would be more precise.
revalidatePath('/products'); // over-invalidates // ❌
// Don't assume the visitor who triggers regeneration sees fresh data.
// They see the stale version. // ❌
// Don't forget that params inside Suspense without Partial Prefetching
// prevents URL-specific ISR entries. // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Stale content served indefinitely | Regeneration fails silently | Check error handling; throw to retry |
| On-demand invalidation does nothing | Static export or unsupported platform | Use a server runtime with ISR support |
| Build time still long | Too many paths in generateStaticParams | Reduce the pre-rendered set; use fallback |
| Page never updates | revalidate not exported or wrong location | Verify route segment config |
| Tag revalidation over-invalidates | Shared tags across unrelated data | Use unique tags per entity |
| Unlisted path serves fallback repeatedly | params awaited inside Suspense without Partial Prefetching | Await params outside Suspense or enable Partial Prefetching |
Real-World Examples
1. Blog Post with Hourly Revalidation
export const revalidate = 3600;
2. Product Page with Tag
fetch(url, { next: { tags: [`product-${id}`] } });
3. CMS Webhook Revalidation
export async function POST(req: Request) {
const { path } = await req.json();
revalidatePath(path);
return Response.json({ revalidated: true });
}
4. Pages Router On-Demand
await res.revalidate('/posts/1');
5. Fallback Blocking
return { fallback: 'blocking' };
6. Dynamic Escape
export const dynamic = 'force-dynamic';
7. Cache Debugging
NEXT_PRIVATE_DEBUG_CACHE=1 next start
8. Static Params Subset
generateStaticParams() { return top100; }
9. Tag Revalidation
revalidateTag('posts', 'max');
10. Error Preservation
if (!res.ok) throw new Error('fetch failed');
Visual
The Stale-While-Revalidate Sequence
┌─────────────────────────────────────────────────────────┐
│ t=0 BUILD │
│ Page generated, cache stored │
│ │
│ t=30 REQUEST │
│ Cache age 30 < revalidate 60 → serve cached │
│ │
│ t=61 REQUEST │
│ Cache age 61 > revalidate 60 → serve STALE │
│ Background regeneration starts │
│ │
│ t=62 REGENERATION COMPLETE │
│ Cache replaced with fresh version │
│ │
│ t=63 REQUEST │
│ Cache age 2 → serve fresh │
└─────────────────────────────────────────────────────────┘
Time-Based vs On-Demand
┌─────────────────────────────────────────────────────────┐
│ TIME-BASED │
│ │
│ revalidate: 60 │
│ Regenerates on the first request after 60 seconds │
│ Predictable, no external trigger needed │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ ON-DEMAND │
│ │
│ revalidatePath('/products/1') │
│ Regenerates on the next request after the call │
│ Triggered by mutation, webhook, or admin action │
│ │
│ Combine both: time-based as baseline, on-demand │
│ for events that must propagate sooner. │
└─────────────────────────────────────────────────────────┘
The Static Export Exclusion
┌─────────────────────────────────────────────────────────┐
│ STATIC EXPORT (output: 'export') │
│ │
│ next build → HTML files → CDN │
│ │
│ No server process. │
│ No cache to regenerate. │
│ No background revalidation. │
│ │
│ ISR is NOT available. │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ NODE.JS SERVER (next start) │
│ │
│ next build → HTML + server → host │
│ │
│ Server stores cache. │
│ Background regeneration runs. │
│ │
│ ISR is available. │
└─────────────────────────────────────────────────────────┘
App Router Params and Suspense
┌─────────────────────────────────────────────────────────┐
│ WITHOUT PARTIAL PREFETCHING │
│ │
│ <Suspense> │
│ await params ← inside boundary │
│ </Suspense> │
│ │
│ Unlisted path → fallback shell served repeatedly │
│ No URL-specific ISR entry created. │
│ │
│ ───────────────────────────────────────────────────── │
│ │
│ WITH PARTIAL PREFETCHING OR AWAIT OUTSIDE │
│ │
│ await params ← outside Suspense │
│ │
│ Unlisted path → shell served, then upgraded │
│ URL-specific ISR entry created. │
└─────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Full name | Incremental Static Regeneration |
| Core mechanism | Stale-while-revalidate cache |
| Time-based trigger | revalidate export |
| On-demand trigger | revalidatePath, revalidateTag |
| Pages Router on-demand | res.revalidate() |
| Fallback behavior | generateStaticParams + dynamicParams |
| Runtime requirement | Node.js server (not static export) |
| Tag granularity | More precise than path invalidation |
| Error behavior | Old cache preserved, retry on next request |
| Dynamic escape | export const dynamic = 'force-dynamic' |
Key takeaways:
- ISR serves stale content while regenerating. The visitor who triggers regeneration does not see the fresh page; the next visitor does .
- Time-based revalidation is the baseline. Set
revalidateto match how often the content actually changes. A low value wastes computation; a high value serves stale data. - On-demand revalidation is the override. Use
revalidatePathorrevalidateTagafter mutations to invalidate specific cache entries without waiting for the interval . - Tags are more precise than paths. A tag invalidates only the cache entries that used the tagged data; a path invalidates everything on the route .
- ISR requires a server runtime. Static export cannot support it. Platform support varies, and on-demand invalidation is less universally available than time-based .
- Failed regeneration preserves the old cache. The stale page continues to serve, and the next request retries. This is a feature: a broken upstream data source does not break the site.
fallbackcontrols ungenerated paths.'blocking'waits,trueshows a loading state,falsereturns 404. The choice depends on SEO and user experience requirements.- The App Router adds Cache Components and Partial Prefetching. The interaction between
params, Suspense, and ISR entry creation is subtle and configuration-dependent . - ISR is not for real-time data. If the content must be fresh on every request, use dynamic rendering. ISR is for content where a minute or an hour of staleness is acceptable.
- The revalidate interval is a product decision, not a technical one. The right value depends on how much staleness the application can tolerate, not on what the framework allows.
Remember: Incremental Static Regeneration is a cache management strategy, not a rendering mode. The page is static; the question is how long the static version remains valid and what happens when it expires. Time-based revalidation answers “how long” with a number. On-demand revalidation answers “what happens” with an explicit invalidation call. The two work together: the interval keeps content from drifting too far, and the on-demand calls handle the events that must propagate immediately. The cost is a running server and a cache that must be managed. The benefit is static performance with controlled freshness, which for a large content site is often the difference between a build that takes hours and one that takes minutes.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!