ISR didn't die — it was decomposed
If you have scaffolded a Sitecore head application on Next.js 16 with the Content SDK's Cache Components template, you have probably hit this moment: you add export const revalidate = 60 to a page the way you always have, and the build rejects it. That is not because Incremental Static Regeneration went away — it is because the template enables Cache Components, a newer caching model in which the old ISR route segment configs are replaced by more explicit tools.
ISR's behavior — static pages, regenerated incrementally — is very much still here; it has new APIs and a better trigger. Sitecore's name for the resulting pattern is on-demand static revalidation (OSR), and since Content SDK 2.2 the SDK ships the pieces to wire it up. This post explains what changed, how the publish-to-page pipeline works end to end, what Experience Edge contributes, and the details that only show up once you run this in production.
A thirty-second recap: SSG, SSR, ISR
Three rendering strategies dominated the Pages Router era.
SSG (Static Site Generation): pages are built once, ahead of time, into ready HTML. Fast to serve, but content goes stale the moment an editor publishes.
SSR (Server-Side Rendering): every request is rendered live. Always fresh, but slower and heavier on infrastructure.
ISR (Incremental Static Regeneration): the compromise — static pages that regenerate after a timer (revalidate: N) or an on-demand trigger.
For CMS-driven sites, ISR always had a rough edge: with time-based revalidation, published content stays stale for up to N seconds, and building precise on-demand invalidation was left as an exercise for each team.
What changed in Next.js 16
Next.js 16 shipped Cache Components, a model where caching is explicit and opt-in rather than inferred (Next.js 16 release notes). You enable it with one flag:
const nextConfig: NextConfig = {
cacheComponents: true,
};
Five primitives replace the old ISR knobs (cacheComponents config).
'use cache' marks a function, component, or page as cacheable (use cache directive).
cacheTag() attaches labels to a cache entry so it can be invalidated precisely (cacheTag).
cacheLife() sets time-based expiry; this is the direct successor to revalidate: N (cacheLife).
revalidateTag(tag, profile) marks every entry carrying a tag as stale. A cache profile is expected as the second argument — the single-argument form is deprecated and may be removed in a future version. With the recommended 'max' profile the semantics are stale-while-revalidate: the next visit serves the cached copy while fresh data loads in the background (revalidateTag).
updateTag() is the read-your-own-writes variant: it expires a tag so the very next request waits for fresh data. It can only be called from a Server Action; in Route Handlers — which is where webhooks land — you use revalidateTag with a profile instead (updateTag, Migrating to Cache Components).
To be clear about scope: Cache Components is opt-in. Without the flag, the classic ISR APIs keep working in Next.js 16 exactly as before (Incremental Static Regeneration). With the flag on, route segments that export dynamic, revalidate, or fetchCache fail the build, and runtime = 'edge' is not supported either — Cache Components requires the Node.js runtime (Migrating to Cache Components).
A useful way to think about it: under Cache Components, ISR is not removed; it is decomposed. The timer (cacheLife) and the on-demand purge (cacheTag + revalidateTag) are now separate tools you compose, instead of one bundled behavior you configure.
When Sitecore support arrived
Sitecore's Content SDK supports Cache Components starting with Content SDK 2.2 (June 2026). That release added cache tag helpers, a ready-made revalidation route handler, and an App Router starter template with Cache Components configured by default (Content SDK 2.2 changelog, What's new in Content SDK 2.2). Note that the SDK offers more than one App Router starter and OSR is optional — if you scaffold with the Cache Components template, you get this model wired in; the plain App Router template leaves the caching strategy to you.
How it works end to end
The full lifecycle, from build to publish to refresh:
build ──► static pages (SSG)
│
visitor ──► cached page (fast)
│
editor publishes in SitecoreAI
│
Experience Edge ──► POST /api/revalidate (webhook)
│
revalidateTag(tag, 'max') marks the affected entries stale
│
next visitor ──► stale copy served, fresh render starts in background
│
visitor after that ──► fresh page, cached again
1. Build
generateStaticParams asks Experience Edge for the site's routes, and every known page is pre-rendered — classic SSG. Paths that do not exist at build time render on first request and are cached from then on.
2. Cache identity
All non-preview content fetches run inside a 'use cache' function. The SDK computes deterministic tags for each entry, in three shapes:
sc:route:{site}:{locale}:{path} — URL-level invalidation
sc:item:{id}:{locale}:{version} — item-level invalidation (version defaults to "latest")
sc:dict:{site}:{locale} — dictionary (translations)
export async function getSitecorePage(params: GetSitecorePageParams) {
'use cache';
const { site, locale, path } = params;
const page = await client.getPage(path, { site, locale });
const tags = collectSitecorePageCacheTags({
site,
locale,
path: client.parsePath(path),
route: page?.layout?.sitecore?.route,
});
for (const tag of tags) {
cacheTag(tag);
}
return page;
}
One nuance worth knowing before you debug: Experience Edge publish webhooks carry item IDs, so the revalidate handler maps them to sc:item:… tags. Route tags (sc:route:…) are only invalidated when a caller explicitly sends the full tag string in the request's tags[] array — the handler does not infer them.
3. Publish
When an editor publishes, the content flows to Experience Edge, and Edge fires a webhook at the head application. The handler is a one-liner from the SDK — it maps the webhook payload to cache tags and calls revalidateTag. Its cacheProfile option defaults to "max", so the cache profile argument is supplied for you:
import { createSitecoreRevalidateRouteHandler } from '@sitecore-content-sdk/nextjs/route-handler';
export const { POST } = createSitecoreRevalidateRouteHandler({
defaultLocale: scConfig.defaultLanguage,
sites,
});
4. Refresh
Because the profile is "max", invalidated entries are marked stale rather than deleted. The next visitor to an affected page still gets the cached copy instantly while a fresh render happens in the background; the visitor after that sees the updated content. No rebuild, no redeploy, no site-wide cache flush — and no visitor waits on the refresh.
Where editing and preview fit
Editing and preview sit outside all of this. The page checks Next.js draft mode and, when it is enabled, fetches uncached data directly:
const draft = await draftMode();
const page = draft.isEnabled
? await client.getPreview(await searchParams)
: cachedPage;
So is this still ISR? Behaviorally, yes: static pages, regenerated incrementally, one at a time. What changed is the trigger — a publish event instead of a timer. Next.js has a dedicated guide on exactly this question, ISR with Cache Components, which also covers prerendering only a subset of routes at build time and letting the rest upgrade after their first visit — worth reading if your site has thousands of pages.
You decide what gets cached
Because nothing is cached without a 'use cache' directive, where you place that boundary is a design decision — and for a Sitecore site the default answer is already made for you. The starter puts the boundary at the data layer: getSitecorePage caches the full layout response, one entry per site, locale, and path. Individual components never cache anything themselves — they render from that already-cached page data, which fits Sitecore's model, where components receive content through the layout response's placeholders and datasources rather than fetching it independently.
Component-level 'use cache' is an opt-in optimization on top of that, and the candidates are pieces that are shared across many pages and make their own fetch — a navigation menu or footer built from a separate GraphQL query, global site settings, an alert banner. Cached once with their own cacheTag, they are fetched a single time, reused by every page, and invalidated on publish like everything else (use cache directive).
A few rules before adding a boundary: only Server Components can be cached ('use client' components cannot take the directive); props become the cache key, so they must be serializable and free of request-specific values; keep editing-aware rendering outside cached scopes; and give every new boundary an explicit cacheTag and cacheLife, or a publish cannot reach it and its lifetime falls back to the implicit default.
The Experience Edge side
The Next.js cache is only half of the pipeline. The other half is Experience Edge, SitecoreAI's hosted delivery layer: published content exposed through a GraphQL API, media served through a CDN, with the content management instance completely out of the production request path (Experience Edge overview, Edge architecture).
Two details matter for this caching story:
Edge has its own cache. Responses from the Edge GraphQL endpoint are cached at Edge's CDN layer, and publishing triggers Edge's internal invalidation for the changed resources. That means a Sitecore site on Cache Components has two server-side cache layers — Edge's CDN and the Next.js data cache — kept in sync by the publish webhook. (The browser adds a third, client-side layer: the Next.js router cache holds server responses for the profile's stale window, coordinated via the x-nextjs-stale-time header (use cache directive).)
Edge webhooks tell you what changed. Experience Edge supports publish webhooks with two execution modes: OnEnd fires when a publishing job completes, and OnUpdate fires with the list of changed entities (webhook execution modes). That entity-level detail is what makes tag-level invalidation possible — the revalidate handler can drop exactly the affected pages instead of flushing the whole site.
It is fair to ask why the head application caches at all if Edge is already fast and cached. Two reasons: latency (every uncached render is a GraphQL round trip) and rate limits — Edge enforces request limits, and a head application that renders from its own cache in steady state barely touches them (Edge rate limits and caching).
Registering the webhook
One setup step is easy to miss: the route handler ships with the starter, but Experience Edge does not call it until you register a webhook. This is a one-time configuration per environment, done through the Edge Admin REST API — authenticate with a JWT from your Edge administration credentials, then create the webhook (Admin REST API):
POST <https://edge.sitecorecloud.io/api/admin/v1/webhooks>
Authorization: Bearer <JWT>
{
"label": "prod-revalidate",
"uri": "<https://www.example.com/api/revalidate>",
"method": "POST",
"executionMode": "OnUpdate",
"headers": { "x-revalidate-secret": "<same value as SITECORE_REVALIDATE_SECRET>" }
}
Choose OnUpdate rather than OnEnd — it includes the changed entity IDs in the payload, which is what lets the handler invalidate only the affected sc:item:… tags (webhook execution modes). The URI must be HTTPS, and Edge debounces delivery, so a large publish does not flood the endpoint.
Two operational notes. First, registration is per environment: production and staging each need their own webhook pointing at their own host, each with the matching secret set in that environment's variables. Second, skipping the webhook does not break the site — pages simply fall back to the default cacheLife cadence and refresh within roughly fifteen minutes of a publish instead of near-instantly. The webhook is what upgrades freshness from "within the revalidation window" to "on publish."
To verify a registration, publish an item and confirm the page updates without a redeploy, or call the endpoint directly with { "tags": ["<item-guid>"] } and the secret header — the handler responds with { "revalidated": true } and the count of tags it invalidated.
What about time-based refresh?
cacheLife() is the successor to revalidate: N — call it inside the same 'use cache' functions, either with a preset profile or a custom one defined in next.config.ts (cacheLife).
Without an explicit call, the default profile applies: 5 minutes of client-side staleness, background revalidation after 15 minutes on the server, and no time-based expiry. Be precise about what that means: the first visitor after the 15-minute window still receives the stale copy and only triggers a background refresh — the visitor after that sees fresh content. And because the default profile never expires by time, a page with no traffic and no webhook has no hard freshness ceiling. A sensible setup treats webhook-driven tags as the primary mechanism and an explicit cacheLife as the backstop.
Migrating an existing app
If you are converting an existing route tree rather than scaffolding fresh, the migration is driven by instant navigation validation: with the flag on, Next.js checks in development whether each route can render instantly and surfaces blocking code as an error or insight. You do not have to convert everything at once — setting instant = false on a segment defers its validation, and a codemod (cache-components-instant-false) can apply that opt-out across the whole app in one pass, so you land the flag first and convert routes one at a time (Migrating to Cache Components). For a large Sitecore site, this is the difference between a big-bang rewrite and an incremental one.
Gotchas from a real implementation
'use cache' is not ISR's persistent cache. This is the one that bites in production. The old ISR cache persisted across serverless instances and deployments; 'use cache' defaults to in-memory storage, so entries are discarded when a serverless instance is torn down, and nothing carries over to a new deployment because the build ID is part of the cache key. Expect cold instances to re-fetch from Edge — which matters given the rate-limit point above. For storage that survives instance teardown, use 'use cache: remote' or a custom cache handler; even then, values recompute after a deploy (use cache directive, use cache: remote).
Keep preview out of the cache helpers. The scaffold calls client.getPreview() directly rather than through the 'use cache' functions — editing requests are parameter-specific and their results do not belong in shared cache entries. Next.js itself also guards this path: while draft mode is enabled, cached functions re-execute on every request and their results are not saved to the cache (use cache directive).
Disable the SDK's in-process dictionary cache (dictionary.caching.enabled: false in sitecore.config.ts). It is a separate memory cache that revalidateTag cannot reach; leaving it on means translations lag behind publishes.
No dynamic, revalidate, or fetchCache segment exports anywhere. All three fail the build under cacheComponents: true, and runtime = 'edge' is unsupported. Older blog posts and muscle memory will both trip on this (Migrating to Cache Components).
generateStaticParams must never return []. With Cache Components an empty array is a build error; when static path generation is disabled, the SDK starter substitutes a build-validation placeholder site instead (Migrating to Cache Components).
Personalization variants share tags, not entries. Each variant renders under a rewritten path, so it gets its own cache entry — but there is no per-variant tag. All variants of a page carry the same item and route tags, so one publish invalidates every variant of that page together. Relevant if you run A/B tests or embedded personalization.
Secure the webhook. When SITECORE_REVALIDATE_SECRET is set, callers must send the same value in the x-revalidate-secret header; when it is unset, the endpoint accepts unauthenticated calls — fine for local testing, not for production.
Fewer NEXT_PUBLIC_ variables than the Pages Router. Server Components read plain environment variables. Only genuinely client-side features need public ones — for example, NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID exists solely for browser-side Edge calls such as analytics and personalization, and Sitecore recommends setting it only when you need those (Context ID environment variables).
Takeaways
ISR is not dead — under Cache Components it lives on with different configuration and a better trigger. The old route-segment knobs become 'use cache', cacheLife, and tags; Sitecore calls the resulting pattern on-demand static revalidation; and for CMS-driven sites the trade is favorable, because invalidation follows publishes instead of timers. Since Content SDK 2.2 the pieces ship in the SDK, and the Cache Components starter template wires them together, so most teams should adopt the pattern as shipped rather than rebuilding it. The version floor is simple to remember: Next.js 16 + Content SDK 2.2 — and if you are migrating an existing app, instant = false lets you get there one route at a time.
Sources
- Next.js 16 release notes
- cacheComponents config — Next.js docs
- use cache directive — Next.js docs
- cacheTag function — Next.js docs
- cacheLife function — Next.js docs
- revalidateTag function — Next.js docs
- updateTag function — Next.js docs
- Migrating to Cache Components — Next.js docs
- Content SDK 2.2 released with on-demand static revalidation — Sitecore changelog
- What's new in Content SDK 2.2 — Sitecore documentation
- ISR with Cache Components — Next.js docs
- Experience Edge — Sitecore documentation
- The architecture of Experience Edge — Sitecore documentation
- The webhook execution modes — Sitecore documentation
- Working with Experience Edge rate limits and caching — Sitecore Accelerate
- use cache: remote directive — Next.js docs
- Context ID environment variables — Sitecore documentation
- Incremental Static Regeneration — Next.js docs
- Admin REST API (Experience Edge) — Sitecore documentation