Skip to content

Writing

Experience Edge Caching + SitecoreAI Content Velocity — Keeping ISR Honest

Muhammad Ali Khan · 03 Nov 2026 · 07 Mins read

Experience Edge Caching + SitecoreAI Content Velocity — Keeping ISR Honest

In June 2025 I wrote about Experience Edge as one layer in a stack of caches, and in November 2025 I wrote about the specific way Netlify kept serving HTML after res.revalidate had already run. Both posts assumed a human publish rate. An author finishes a page, a workflow advances, a webhook or a script fires, the CDN catches up. SitecoreAI did not replace that chain. It shortened the gap between drafts. A workflow agent can create and update a batch of pages in the time a single author used to spend on one, and every one of those pages still becomes public only when it is published and only when every cache in front of Experience Edge agrees to let go.

The bug I chase now is a green agent run sitting next to a stale ISR entry. The job succeeded. Edge has the new layout data, or it will within the publishing pipeline. The Next.js cache does not know, because nothing told it, or because we told it to drop the entire site on every item and the origin folded under its own invalidation. This is the setup I use to keep incremental static regeneration honest when content velocity goes up.


Publish is still the invalidation event

An agent writes items through the tools and the Agent API. Those writes follow Sitecore's security and approval rules, and they return a job id. They are not, by themselves, a signal to Experience Edge's delivery cache or to the head. I have watched a team wire /api/revalidate to the end of an Agentic Studio flow and then argue about why the public site changed before the page was approved. The flow had called the head. The workflow had not reached publish. The public site should not move.

The event I subscribe to is the publish reaching Edge. Sitecore's delivery webhooks have two modes that matter here. OnEnd runs at the end of the publishing job, after caches are purged, and it can send a body you defined. OnUpdate sends the entity changes that caused the webhook, in JSON, and it does not take a custom body. Updates are batched. I use OnUpdate when I want the head to know which items changed, and I keep the handler fast enough that a burst of batches does not time out. Sitecore will deactivate webhooks that fail repeatedly. A handler that tries to rebuild the site inside the request is how you get deactivated.

Content SDK's App Router cache-components template already has the shape I want. Reads of page and dictionary data go through helpers that tag the Next.js cache with sc:* tags. POST /api/revalidate is createSitecoreRevalidateRouteHandler. The handler reads updates[], strips the -media and -layout suffixes Sitecore puts on identifiers, and revalidates sc:item tags for those ids, using entity_culture as the locale. A tags[] array can pass sc: tags through directly. When SITECORE_REVALIDATE_SECRET is set, the caller must send the same value in the x-revalidate-secret header. I set the secret. An unauthenticated revalidate URL is an anonymous cache purge, and once agents exist there are more systems that will eventually be pointed at it by mistake.


What I refuse to invalidate

revalidatePath('/', 'layout') is the blunt instrument in the platform guides, and it is a reasonable first wire-up for a small site. It marks the tree stale and lets the next visit rebuild each path. It does not render ten thousand pages inside the webhook, which is the sentence that keeps people from fearing it. On a site where an agent publishes a section every few minutes, the blunt instrument marks the whole tree stale every few minutes. Popular pages rebuild constantly. Unpopular pages rebuild on the next stranger who hits them, which is fine. The popular ones are the bill, and they were not all related to the item that changed.

Tag-based invalidation is the correction. The page cache entry has to have been written with the item tags in the first place, which is what the Content SDK cache helpers do when you use them. A hand-rolled fetch of layout data that never sets tags will log a successful revalidate and change nothing. I have read that log and declared the webhook "working." The handler's contract is that it only invalidates entries created with matching tags. No tags, no effect, HTTP 200.

Shared pieces need their own tag. A dictionary update, or a navigation datasource used by every page, is the case where a wide purge is honest. The handler can append dictionary tags per site when you configure the sites. I would rather revalidate the dictionary tag than revalidate every article because the footer label changed, and I would rather revalidate every article than serve a stale dictionary for a day. The choice is about the dependency, which I write down. It is not about how anxious the last publish made us.

Preview and editing stay out of this cache. The page builder and the local editing host need fresh layout data on each render. A preview route that is eligible for ISR will show an author the public version and convince them the publish failed. page.mode is the signal Content SDK gives me. Public ISR is for isNormal. Editing, preview, and Design Library renders bypass the public cache and do not write into it.


A Pages Router handler that stays boring

The App Router template already ships the handler. The Pages Router apps we have not moved yet need an equivalent that does less, not more. I verify the secret, I acknowledge the batch, and I revalidate by tag or by the specific paths I can derive. I do not call Netlify, Vercel, and a custom CDN in the same function without an explicit host. The November 2025 post is still the Netlify-specific half: Netlify-Cache-Tag on the HTML and a purge by that tag, because Netlify's edge does not honor res.revalidate alone. If the site is on Netlify, that purge stays. The Sitecore webhook becomes the trigger, and the tag map has to agree. A Sitecore item id and a Netlify tag of page:en_products_abc are only related if we keep a table. I do not pretend the Edge identifier is a URL.

type Update = { identifier?: string; entity_culture?: string };

export function itemTag(update: Update, defaultLocale = "en"): string | undefined {
  if (!update.identifier) return undefined;
  const id = update.identifier.replace(/-(media|layout)$/i, "");
  const locale = update.entity_culture || defaultLocale;
  return `sc:item:${id}:${locale}:latest`;
}

The tag format has to match the tags the page helper wrote. I treat the Content SDK helper as the authority and I copy its format out of the version we installed, not out of a blog, including this one. If a minor release changes the tag, the webhook and the reader move together. A webhook that revalidates yesterday's tag shape is a silent no-op, which is the most expensive kind.

I also dedupe inside the batch. OnUpdate can mention the same entity more than once across a burst. Revalidating the tag twice is harmless and noisy. Revalidating the layout root twice because two items in the batch were the homepage datasource is how a single publish becomes a stampede. The homepage gets one invalidation per batch.


Agents change the volume, so the workflow has to change the fan-out

A bulk content flow that publishes fifty pages will produce a webhook burst. That is acceptable when each page's tag is invalidated and the next reader pays for a rebuild of that page. It is not acceptable when each of the fifty calls also purges the shared chrome by path. I configure the flow to publish once at the end of the batch if the product path we are on allows a single publish job, and I let OnEnd fire once for the job plus OnUpdate for the entities. I do not add a revalidate action inside every workflow step.

The head's revalidate window, the time-based one, stays long. Time-based ISR is the safety net for a missed webhook, not the primary freshness mechanism. A five-second revalidate on a busy site rebuilds forever and still races the agent. I would rather a one-hour safety net and a webhook that names items. Edge's own cache still sits under that. Nothing in SitecoreAI disables the CDN in front of the GraphQL endpoint. A head that bypasses its ISR cache and hits Edge on every request will discover Edge's TTL instead, and will call it random. I cache in the head, I invalidate on publish, and I let Edge do its job for the cache misses.

Static assets and images follow the image loader we already have. I do not purge the image cache because a headline changed. I do purge the page tag, and if the changed item is a media item the identifier suffix tells the handler it was media. The page that embeds the media still has to be tagged with that media id, or the new asset waits for the page's own TTL. That dependency is the one teams forget when an agent "replaces the hero image" and the HTML keeps pointing at a cached page that embeds the old URL.


The order I put this in place

  1. Confirm the public site reads published Edge content, and that preview uses a different mode that is not written into the public ISR cache.
  2. Turn on an OnUpdate webhook to /api/revalidate with x-revalidate-secret. Start with a non-production publish.
  3. Use the Content SDK handler where the App Router template already has it. On Pages Router, revalidate the same tags the page writer used, and return immediately.
  4. Keep host-specific CDN purges, such as the Netlify tag purge from last year, beside that handler only when the HTML is cached again in front of Next.js.
  5. Run one bulk agent publish in a lower environment and count invalidations. If the count looks like "the whole site, fifty times," stop and tag the shared pieces separately before anyone points the flow at production.

Velocity is the feature authors wanted from SitecoreAI. Honesty is the feature readers wanted from the cache. Both are available when the only automatic invalidation is a publish, the only tags we purge are the tags we wrote, and an agent job is allowed to finish without touching the CDN at all.

Related notes