Skip to main content

How incremental static regeneration (ISR) works in Next.js

ISR (Incremental Static Regeneration) - a Next.js strategy that pre-renders pages statically at build time, then regenerates individual pages in the background after a revalidate timer expires, without touching the rest of the app.

Theory

TL;DR

  • Think of a coffee shop with pre-brewed pots. The pot is ready on the counter (static), but when it runs low the machine starts a new brew automatically. You get what was already poured; the next customer gets fresh coffee.
  • Main difference: SSG requires a full next build to update content. ISR updates individual pages on a timer with no rebuild. SSR re-renders on every request; ISR serves from cache.
  • Decision rule: data changes every few minutes to a few hours and most requests hit the same pages? ISR fits. Data is user-specific? SSR. Never changes? Plain SSG.
  • First request after the timer expires gets the stale version. The next request gets fresh content.
  • On-demand revalidation (revalidatePath, revalidateTag) is more precise than a timer: you push updates exactly when data changes.

Quick example

tsx
// pages/posts.js - basic ISR, Next.js 12+ export async function getStaticProps() { const posts = await fetch('https://api.example.com/posts').then(r => r.json()); return { props: { posts }, revalidate: 60 // re-generate at most once per 60 seconds }; } export default function Posts({ posts }) { return <ul>{posts.map(p => <li key={p.id}>{p.title}</li>)}</ul>; } // t=0: static HTML served from CDN. // t=61s, request 1: gets stale HTML, getStaticProps fires in background. // t=61s, request 2: gets freshly generated page.

The revalidate: 60 field is serialized into the static JSON payload at build time. Next.js reads it on every cache check and decides whether to queue a background regen.

Key difference

ISR is SSG with a built-in expiry hook. At build, getStaticProps runs and Next.js writes /_next/static/[page].json plus the HTML shell to disk or CDN. That JSON also carries the revalidate value. When a request arrives after the timer expires, the Node.js server queues a background getStaticProps call, writes new HTML and JSON atomically via fs.promises, and keeps serving stale until the write completes. No downtime, no full rebuild, no user ever waits.

When to use

  • Data updates hourly or daily (blog posts, product catalogs, documentation) - ISR over SSG
  • High traffic, low change frequency - ISR over SSR (no per-request render cost)
  • Real-time or per-user data - SSR instead
  • Content that never changes - pure SSG, ISR adds no value here
  • Frequent changes plus user-specific content - SSR or client-side fetching

Comparison table

SSGISRSSR
GenerationAt buildAt build + backgroundEvery request
First loadInstant (CDN)Instant (CDN)~100-500ms server
UpdatesFull next buildBackground on timerEvery request
Stale readsNeverYes, until regen completesNever
Use whenDocs, marketingNews, catalogs, dashboardsProfiles, carts

How Next.js handles regeneration internally

At build, getStaticProps serializes props to /_next/static/[page].json with a revalidate field and deploys HTML to CDN. On a post-expiry request (checked via Vercel Edge headers or a Node cache timestamp), Next.js does four things in order:

  1. Serves the stale page immediately to the current user
  2. Queues getStaticProps on a worker thread
  3. Writes new JSON and HTML atomically to the filesystem or CDN edge
  4. Serves stale to all concurrent requests during that window

If getStaticProps throws during background regen, Next.js logs the error and keeps the last successful stale version alive. The page stays up.

ISR in App Router

In App Router the revalidate option moves into the fetch call itself:

tsx
// app/problems/page.tsx export default async function ProblemsPage() { const res = await fetch('https://api.itlead.org/problems', { next: { revalidate: 300 } // 5-minute cache window }); const problems = await res.json(); return ( <ul> {problems.map((p: { id: string; name: string }) => ( <li key={p.id}>{p.name}</li> ))} </ul> ); }

You can also set it at the route segment level:

tsx
// app/problems/layout.tsx export const revalidate = 300; export default function ProblemsLayout({ children }: { children: React.ReactNode }) { return <>{children}</>; }

For on-demand updates, use revalidatePath or revalidateTag. In practice, I find this pattern far more useful than a timer: you invalidate exactly when a CMS webhook fires, not after a fixed window.

tsx
// app/api/revalidate/route.ts import { revalidatePath, revalidateTag } from 'next/cache'; import { NextResponse } from 'next/server'; export async function POST(request: Request) { const { secret, path } = await request.json(); if (secret !== process.env.REVALIDATION_SECRET) { return NextResponse.json({ error: 'Invalid secret' }, { status: 401 }); } revalidatePath(path); return NextResponse.json({ revalidated: true }); }

Tag-based invalidation works across multiple pages at once:

tsx
// Tag your fetch const res = await fetch('https://api.itlead.org/problems', { next: { tags: ['problems'] } }); // Invalidate all pages that used that tag revalidateTag('problems');

Common mistakes

Setting revalidate: 0 expecting per-request rendering

tsx
// Wrong - this is not getServerSideProps return { props: { data }, revalidate: 0 };

revalidate: 0 does not give you SSR behavior. The page is still static and attempts to regenerate on every request, but remains subject to caching layers. Use getServerSideProps for true per-request rendering.

Mutating module-level state inside getStaticProps

tsx
// Wrong let cache: string[] = []; export async function getStaticProps() { cache.push(await fetchData()); // lost between Lambda invocations }

getStaticProps runs in isolated Lambda or VM contexts. Module-level variables do not survive between calls. Use Redis, a database, or unstable_cache() in Next.js 14+ for shared state.

Ignoring TTFB spikes from fallback: 'blocking' under load

tsx
export async function getStaticPaths() { return { paths: [{ params: { slug: 'post1' } }], fallback: 'blocking' }; }

With fallback: 'blocking', unknown slugs are rendered server-side on first request, then cached as ISR. That first render is blocking and serial. Under traffic spikes for new slugs, p99 TTFB jumps. Pre-build popular paths or use fallback: true with a loading state for lower latency.

Long-running getStaticProps over 10 seconds

Vercel kills build-time and request-time executions after 10 seconds. If your CMS returns 500 items, paginate. For large sites, add experimental: { workerThreads: true } in next.config.js and split fetches.

Preview mode without revalidate: false

When preview mode is active, the revalidate timer is ignored, but draft content can still leak into the CDN cache. Return revalidate: false explicitly:

tsx
if (preview) return { props: { draftData }, revalidate: false };

Real-world usage

  • Vercel blog: revalidate: 60, posts update without a deploy
  • Stripe docs: product changelogs via a Git-based CMS, revalidate: 3600
  • Hashnode: blog feeds use time-based ISR, new author pages use fallback: blocking
  • Lee Robinson's blog: talks list fetched from Airtable, regenerated via ISR
  • TinaCMS + Next.js starter: ISR combined with preview mode for headless CMS editing

Follow-up questions

Q: How does ISR handle concurrent requests during regeneration?
A: All requests during regeneration get the stale page. Next.js uses atomic fs.writeFile plus ETag checks to avoid race conditions. Traffic shifts to the new version only after the write completes.

Q: What is the difference between revalidate: 60 in getStaticProps and revalidatePath()?
A: revalidate is time-based: the page regenerates at most once per N seconds after the first post-expiry request. revalidatePath('/path') is on-demand: you call it explicitly from a Route Handler or Server Action, and the cache clears immediately regardless of any timer.

Q: What happens if getStaticProps throws during background regeneration?
A: Next.js logs the error and keeps serving the last successful stale version. It does not fall back to SSR. The page stays alive until a regeneration succeeds.

Q: Does ISR work the same way on self-hosted Node vs Vercel?
A: The mechanism is the same, but self-hosted requires a persistent filesystem or a custom cache handler for multi-instance setups. Vercel auto-scales workers and shares the cache across instances via its Edge Network with no extra config.

Q (senior): In App Router (Next.js 14+), how does { next: { revalidate: 60 } } on a fetch differ from revalidatePath? What cache layers are involved?
A: fetch revalidation targets the Data Cache (per-request cached responses stored server-side). revalidatePath invalidates the Full Route Cache (the rendered RSC payload plus HTML) and also purges the client-side Router Cache. If you only call revalidatePath, stale fetch responses can still be served from the Data Cache until their own timer expires. For full freshness you often need both: revalidateTag on the fetch side, revalidatePath for the route cache.

Q (senior): How would you scale ISR across 10,000 pages with varying revalidate intervals?
A: Skip pre-building all 10k at deploy time. Use dynamic segments with fallback: 'blocking' so pages are built on first request and cached as ISR. Set shorter revalidate on high-traffic routes and longer on the long tail. On self-hosted setups, configure a Redis-based custom cache handler to avoid filesystem contention across multiple Node instances. Monitor regen queue depth via Vercel Analytics or a custom metric.

Examples

Basic: blog post list with time-based ISR (Pages Router)

tsx
// pages/posts.tsx interface Post { id: number; title: string; } export async function getStaticProps() { const posts: Post[] = await fetch('https://api.example.com/posts') .then(r => r.json()); return { props: { posts }, revalidate: 60 }; } export default function Posts({ posts }: { posts: Post[] }) { return ( <ul> {posts.map(post => <li key={post.id}>{post.title}</li>)} </ul> ); } // t=0: static HTML, CDN. // t=61s request 1: gets stale, background regen starts. // t=61s request 2: gets freshly generated page.

The revalidate: 60 in the return value is the entire ISR contract with Next.js. Everything else is standard getStaticProps.

Intermediate: product catalog from Contentful with 5-minute revalidation and safe preview mode

tsx
// pages/products.tsx import { createClient } from 'contentful'; interface Product { sys: { id: string }; fields: { name: string; price: number }; } export async function getStaticProps({ preview = false }: { preview?: boolean }) { const client = createClient({ space: process.env.CONTENTFUL_SPACE_ID!, accessToken: preview ? process.env.CONTENTFUL_PREVIEW_TOKEN! : process.env.CONTENTFUL_ACCESS_TOKEN!, }); const entries = await client.getEntries<any>({ content_type: 'product', limit: 20, }); // Preview mode: never cache draft content if (preview) { return { props: { products: entries.items }, revalidate: false }; } return { props: { products: entries.items }, revalidate: 300 // 5 minutes covers most price/stock update cadences }; } export default function Products({ products }: { products: Product[] }) { return ( <div> {products.map(p => ( <div key={p.sys.id}> <h2>{p.fields.name}</h2> <p>${p.fields.price}</p> </div> ))} </div> ); }

Without revalidate: false in preview mode, draft content can slip into the CDN cache and reach real users. This pattern from the ISR docs is easy to miss and shows up in production incidents more often than you would expect.

Advanced: dynamic slugs with fallback: 'blocking' plus webhook-driven on-demand revalidation (App Router)

tsx
// app/articles/[slug]/page.tsx interface Article { slug: string; title: string; body: string; } // Pre-build only top 10 at deploy; unknown slugs render on first request export async function generateStaticParams() { const articles: Article[] = await fetch( 'https://api.example.com/articles?limit=10' ).then(r => r.json()); return articles.map(a => ({ slug: a.slug })); } export const dynamicParams = true; // allow unknown slugs export const revalidate = 10; // timer as a safety net export default async function ArticlePage({ params }: { params: { slug: string } }) { const article: Article = await fetch( `https://api.example.com/articles/${params.slug}`, { next: { tags: [`article-${params.slug}`] } } // tag for targeted invalidation ).then(r => r.json()); return ( <article> <h1>{article.title}</h1> <p>{article.body}</p> </article> ); }
tsx
// app/api/revalidate/route.ts - called from CMS webhook on publish import { revalidateTag } from 'next/cache'; import { NextResponse } from 'next/server'; export async function POST(request: Request) { const { secret, slug } = await request.json(); if (secret !== process.env.REVALIDATION_SECRET) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } revalidateTag(`article-${slug}`); return NextResponse.json({ revalidated: true, slug }); }

The first request for an unknown slug blocks server-side (SSR-like) and then gets cached as an ISR page. After that, the CMS fires the webhook on publish and revalidateTag clears the cache immediately. The 10-second timer acts as a fallback if the webhook fails. Both mechanisms working together give you real-time updates without giving up static speed for known pages.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.

Finished reading?