Next.js ISR Implementation

Incremental Static Regeneration serves pre-rendered HTML from the edge while regenerating individual pages in the background, so content updates ship without a full rebuild and without falling back to per-request SSR. For a headless CMS, that decouples deploys from publishes: editors get fresh content within a configurable window while readers still get static-fast TTFB. ISR sits at the center of most Data Fetching & Caching Strategies, and this topic covers both routers: the Pages Router’s getStaticProps contract and the App Router’s fetch cache with revalidateTag.

Integration Contract

An ISR integration touches three systems, and each one contributes a piece of configuration that must agree with the others. The CMS sends webhooks when content changes. The Next.js application holds the rendered pages and data in its cache and exposes a revalidation endpoint. The CDN in front of the application decides how long it keeps its own copy of each page. When a publish does not show up, one of these three contracts is almost always the cause.

On the Next.js side, the API surface depends on the router. The Pages Router uses getStaticProps returning revalidate: n, plus res.revalidate(path) inside an API route for on-demand updates. The App Router uses route segment config (export const revalidate = 60), per-request fetch(url, { next: { revalidate, tags } }), and the revalidatePath and revalidateTag functions from next/cache, called from a route handler or server action. Both routers can coexist in one application, and both write to the same underlying cache.

The auth model is a shared secret. The CMS webhook either carries a token in the URL or signs the payload with an HMAC. The revalidation handler must reject anything else, because an open revalidation endpoint lets anyone force regeneration of every page and turn your CMS rate limit into an outage.

Bash
# .env: ISR integration contract
CMS_DELIVERY_TOKEN=cda_read_only_published_content
CMS_PREVIEW_TOKEN=cpa_server_only_drafts
REVALIDATE_SECRET=4f9c2e7a0b5d4c1e8a3f6b9d2c5e8a1f   # shared with the CMS webhook
ISR_DEFAULT_REVALIDATE=300                          # seconds, fallback window
NEXT_PRIVATE_DEBUG_CACHE=1                          # logs cache HIT/MISS in development

The time-based window (ISR_DEFAULT_REVALIDATE) is a safety net, not the primary freshness mechanism. With webhooks wired up, publishes appear within seconds and the window only matters when a webhook is lost.

Revalidation mechanics

ISR runs on the revalidate threshold, in seconds. A request to an ISR route gets the cached HTML immediately. If more than revalidate seconds have passed since the last successful generation, Next.js queues a background regeneration — the current visitor still gets the cached page, the next one gets the rebuilt page.

The serve-stale-then-regenerate contract plays out per request like this:

The ISR request decision pathA request to an ISR route either triggers first-time generation through the fallback path, serves cached HTML inside the revalidate window, or serves cached HTML while queueing a background regeneration.Request/articles/[slug]Pagegenerated?fallback: blockingfetch CMS + renderWindowelapsed?Serve cached HTMLServe stale HTMLqueue regenerationnoyesnoyes
Every visitor gets an immediate response except the first visitor to an ungenerated route under fallback blocking.

The cache boundary lives at the framework and CDN layer, which is the same serve-stale-then-refresh contract as SWR Stale-While-Revalidate Patterns, just not in browser memory.

Two exports drive it: getStaticPaths enumerates routes, getStaticProps hydrates data. revalidate is the tuning knob — too low raises origin load and regeneration collisions, too high delays editorial updates. A 60–300s window works for most content sites. Next.js respects standard HTTP caching, so edge networks serve cached responses until regeneration completes (MDN: HTTP Caching).

One route across a 60-second revalidate windowTimeline of a route with revalidate set to 60 seconds: fresh for a minute, then the first request after expiry serves stale content while regeneration runs, after which the new page is served.Cached HTML v1fresh, then staleRegenerationCached HTML v2served after rebuild0 s25 s50 s75 s100 s125 s150 swindow endsfirst request after
The first visitor after the window expires still receives the old page; regeneration finishes a few seconds later.

The timeline shows why “revalidate: 60” does not mean “content is at most 60 seconds old”. A page on a quiet route can stay stale for hours, because regeneration only starts when a request arrives after the window ends. On-demand revalidation removes that dependence on traffic.

Implementation

Dynamic CMS routing with typed props and ISR:

TSX
// pages/articles/[slug].tsx
import { GetStaticProps, GetStaticPaths } from 'next';
import { fetchArticleBySlug, fetchAllArticleSlugs } from '@/lib/cms-client';

interface Article {
  id: string;
  slug: string;
  title: string;
  publishedAt: string;
  bodyHtml: string;
}

interface ArticlePageProps {
  article: Article | null;
}

export default function ArticlePage({ article }: ArticlePageProps) {
  if (!article) {
    return <div className="error-state p-8 text-center">Article unavailable</div>;
  }

  return (
    <article className="prose max-w-3xl mx-auto px-4 py-12">
      <h1>{article.title}</h1>
      <p className="text-gray-500 text-sm">Published: {article.publishedAt}</p>
      <div dangerouslySetInnerHTML={{ __html: article.bodyHtml }} />
    </article>
  );
}

export const getStaticPaths: GetStaticPaths = async () => {
  const slugs = await fetchAllArticleSlugs();
  return {
    // Pre-build only high-traffic or recently published content
    paths: slugs.slice(0, 50).map(slug => ({ params: { slug } })),
    fallback: 'blocking',
  };
};

export const getStaticProps: GetStaticProps<ArticlePageProps> = async ({ params }) => {
  const slug = params?.slug as string;
  const article = await fetchArticleBySlug(slug);

  if (!article) {
    return { notFound: true };
  }

  return {
    props: { article },
    // Regenerate at most once every 60 seconds per route
    revalidate: 60,
  };
};

The App Router equivalent

New projects use the App Router, where the unit of caching is the fetch call, not the page. Each CMS request carries its own revalidate window and a set of tags. A webhook later invalidates tags, not paths, which matters for content that appears on many pages:

TSX
// app/articles/[slug]/page.tsx
import { notFound } from "next/navigation";

interface Article {
  sys: { id: string };
  slug: string;
  title: string;
  bodyHtml: string;
}

async function getArticle(slug: string): Promise<Article | null> {
  const res = await fetch(`${process.env.CMS_API_URL}/articles?slug=${encodeURIComponent(slug)}`, {
    headers: { Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN}` },
    // Tag by content type and by slug so a webhook can target either.
    next: { revalidate: 300, tags: ["article", `article:${slug}`] },
  });
  if (!res.ok) throw new Error(`CMS responded ${res.status}`);
  const items = (await res.json()) as Article[];
  return items[0] ?? null;
}

export async function generateStaticParams(): Promise<{ slug: string }[]> {
  const res = await fetch(`${process.env.CMS_API_URL}/articles?select=slug&limit=50`, {
    headers: { Authorization: `Bearer ${process.env.CMS_DELIVERY_TOKEN}` },
  });
  return ((await res.json()) as { slug: string }[]).map(({ slug }) => ({ slug }));
}

export const dynamicParams = true; // equivalent of fallback: "blocking"

export default async function ArticlePage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const article = await getArticle(slug);
  if (!article) notFound();
  return (
    <article>
      <h1>{article.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: article.bodyHtml }} />
    </article>
  );
}

Throwing on a non-OK response is deliberate. During background regeneration, a thrown error aborts the regeneration and Next.js keeps serving the last good page. Returning null would instead cache a 404 for a page that exists.

Fallback behavior

The fallback value in getStaticPaths controls requests for routes not pre-built. fallback: 'blocking' makes the server wait for the CMS fetch and generation before responding — no loading skeleton, but higher TTFB on uncached routes. fallback: true returns a shell immediately with a loading state, better for high-traffic sites but it requires careful hydration handling. With fallback: true, article arrives as null on first render, so you need a guard that shows a loading UI until regeneration completes and Next.js re-renders with data. Structuring those states is covered in resolving Next.js ISR fallback pages for missing CMS content.

Revalidation tuning and CDN sync

ISR depends on tight sync between the Next.js server, the CMS, and the edge CDN. When revalidate expires, regeneration happens on origin — but distributed CDNs keep serving stale edge caches until their own TTL expires or an explicit purge arrives. Time-based revalidation alone leaves a gap between CMS publish and edge visibility. Closing it means webhook-driven on-demand revalidation via /api/revalidate; coordinating these layers is detailed in handling cache invalidation across distributed CDNs.

ISR only covers server-rendered routes. For client-side mutations, interactive dashboards, or comment threads, layer a client cache: React Query for CMS Data handles optimistic updates and background sync without disturbing ISR’s static baseline.

Caching & Invalidation Strategy

ISR adds one cache tier to the headless stack, and it sits between two others that it does not control. Below it, the CMS’s own delivery CDN caches API responses for a short time. Contentful’s CDN, for example, can return a response that is a few seconds old right after a publish. Above it, your CDN caches the HTML that Next.js returns, according to the Cache-Control header. On Vercel that header is managed for you; self-hosted, Next.js sends s-maxage=<revalidate>, stale-while-revalidate, and a CDN such as CloudFront or Fastly honours it.

On-demand revalidation should therefore be ordered: verify the webhook, wait briefly or retry until the CMS API returns the new version, invalidate the Next.js tag, then purge the CDN path if your CDN does not respect the regenerated response’s headers. The on-demand revalidation guide implements exactly that order, and the distributed CDN guide covers purges across regions.

On-demand revalidation after a publishThe CMS sends a signed webhook to the revalidation route, which verifies it, invalidates the matching cache tags, and the next request regenerates the page with fresh CMS data.CMSRevalidate routeNext.js cacheReaderPOST entry.publish (signed)verify HMACmap entry to tagsrevalidateTag(article:slug)200 { revalidated: true }GET /articles/slugtag stale: refetch CMSrender + storefresh HTML
The webhook invalidates; the next request regenerates. Nothing is rendered inside the webhook handler itself.

Tag design decides how precise invalidation can be. A useful convention is three tag levels per fetch: the content type (article), the entry (article:<id>), and any shared dependency such as navigation or author:<id>. A single webhook can then invalidate one article, every article after a template change, or every page that shows a given author, without touching unrelated pages. Paths are a poor substitute: a renamed slug leaves the old path cached, and content that appears on listing pages is invisible to path-based invalidation.

Choosing between time-based and on-demand freshness

Both mechanisms are needed, and they have different jobs. On-demand revalidation delivers freshness: it reacts to a publish within seconds, regardless of traffic. The time-based window delivers resilience: it bounds staleness when a webhook is lost, misrouted or rejected. Treating the window as the freshness mechanism leads to very short windows, wasted regenerations and CMS rate-limit trouble. Treating webhooks as infallible leads to revalidate: false everywhere and pages that stay wrong for weeks after a single lost webhook.

A practical split is to set windows from how long you can tolerate an undetected webhook failure, typically an hour for editorial content and a day for evergreen pages. Freshness then comes from webhooks, and the interval guide turns that principle into per-type numbers. Monitor webhook delivery, too: most CMS platforms expose a delivery log with response codes, and a dashboard of non-2xx responses from your revalidation route catches misconfiguration before editors do.

Schema & Content Modeling Considerations

The content model decides what a webhook payload can tell you, and therefore which tags you can invalidate. A webhook for an Author entry does not list the articles that reference that author. If the frontend renders author bios inline on article pages, invalidating author:<id> only works if every article fetch that includes the author also carries that tag. Build tags from the data you fetched, not from the route: after resolving references, add author:<id> for every author in the response.

Slugs deserve special care. ISR caches by path, so a slug change in the CMS creates a new path and leaves the old one cached and still reachable. Model slugs as a field with a history (previousSlugs), and on publish invalidate both the new and old paths and emit a redirect from the old one. The 404 and redirect guide covers the redirect side.

Payload size affects regeneration time directly. A regeneration that fetches a page with fifty nested references across four levels can take seconds, and a slow regeneration keeps the stale page in place longer. Resolve only the depth the page renders, and use the content modeling guidance to keep reference chains shallow.

Preview & Draft Workflow

ISR and preview are mutually exclusive by design. Drafts must never be written to the ISR cache, because every reader would then see them. In the Pages Router, res.setPreviewData() makes getStaticProps run on every request with context.preview set. In the App Router, draftMode().enable() sets a bypass cookie and every fetch in that request skips the cache. In both cases, the data layer must switch to the preview token and the preview endpoint when draft mode is on. The shared rules for draft/publish state apply unchanged.

The common mistake is a fetch with next: { revalidate: 300 } that ignores draft mode. The cookie bypasses the cache, but if the request still goes to the delivery endpoint with the delivery token, editors see published content in preview and assume the preview is broken. Centralize the choice in one cmsFetch helper that reads draftMode() and picks the endpoint, token and cache options together.

Error Handling & Resilience

ISR fails safe by default. When regeneration throws, Next.js logs the error, keeps the previous page and tries again on the next request after the window. That makes errors invisible to readers, which is good, but it also means a broken CMS integration can serve week-old pages without anyone noticing. Log regeneration failures with the route and the CMS status code, and alert on repeated failures for the same route.

Distinguish between “the CMS is down” and “the entry is gone”. A 5xx or timeout from the CMS should throw, so the stale page survives. A confirmed missing entry should return notFound: true in the Pages Router or call notFound() in the App Router, so the route is removed. Rate limits (HTTP 429) during a mass regeneration after a big publish are the most common production failure. Stagger on-demand revalidation for bulk publishes and prefer tag-level invalidation, which regenerates pages lazily as they are requested, over looping through every path.

Testing & Observability

Every ISR response carries an x-nextjs-cache header with HIT, STALE or MISS on self-hosted deployments, and Vercel adds x-vercel-cache. These headers are the ground truth for whether a publish has reached the cache. An end-to-end test can publish a change through the CMS management API, trigger the webhook, request the page twice and assert on the second response’s content and header. The automated testing for headless integrations topic covers the fixtures for that, and the stale-page debugging guide walks through reading these headers in production.

Instrument the revalidation route itself: log every webhook with the entry id, the tags invalidated and the time since the CMS published. The difference between the CMS’s publishedAt and the first HIT for the new content is your end-to-end publish latency, and it is the single most useful ISR metric to put on a dashboard.

Production checklist

  • Tune revalidate per route type: 30–60s for news feeds, 3600s+ for evergreen docs.
  • Wire CMS webhooks to /api/revalidate so publishes update the edge immediately instead of waiting on the time-based cycle.
  • Watch for regeneration collisions: track x-nextjs-cache: REVALIDATING; if collisions spike, raise revalidate or deduplicate at the CDN.
  • Load-test uncached routes so fallback: 'blocking' doesn’t exhaust serverless execution limits.
  • Sanitize CMS payloads: validate dangerouslySetInnerHTML content or use a strict Markdown parser to prevent XSS in statically cached pages.

Frequently Asked Questions

What revalidate value should I start with?

Start with 300 seconds for editorial pages and rely on webhooks for freshness, then adjust per content type. The interval guide gives a table of starting values and explains how traffic patterns change the effective staleness.

Does revalidatePath or revalidateTag regenerate the page immediately?

Neither renders anything at call time. Both mark cached data or pages as stale, and the next request triggers regeneration. For a page that nobody visits, the regeneration may never happen, which is fine because nobody sees the stale version either.

Can I use ISR when self-hosting Next.js on several servers?

Yes, but the default cache is the local filesystem of each server, so instances disagree after a revalidation. Configure a shared cacheHandler backed by Redis or another shared store, as shown in the self-hosted ISR guide.

Why do editors still see old content after a webhook fired?

Usually because the CMS’s own CDN returned the previous version to the regeneration request, or because your CDN kept its copy of the HTML. Add a short retry that compares the entry version in the webhook with the version the API returns, and purge the CDN path after revalidating.

How does ISR interact with a service worker or PWA cache?

A service worker that caches HTML adds a fifth tier the server cannot reach. Use a network-first strategy for navigations so readers get the regenerated page whenever they are online, and reserve cache-first for hashed static assets.