SitecoreAI · Article

Caching Sitecore with Next.js Cache Components

The Content SDK Cache Components template caches and tags every published page. Which dependencies it tags, how long pages live and what a webhook cannot tell you are still your decisions.

Illustration of a gardener with a pole raising a wooden sluice gate among stepped terraces, releasing water into one lower channel while the other pools lie still, representing a change reaching only the parts that depend on it

Content SDK 2.2 gave SitecoreAI a Next.js Cache Components template. It caches every published page, tags it, and provides a revalidation endpoint you can point an Experience Edge webhook at, so those tags are invalidated when content is published. It is a strong starting point, and it leaves three decisions to the team that adopts it: which dependencies each page is tagged with, how long each cache may live, and what happens when a change event cannot say what went stale.

Those decisions were the subject of our session, Caching Sitecore: a deep dive into Next.js Cache Components, at the Sitecore User Group France virtual event, and the recording is on YouTube. This article covers the same ground in more depth, with the code and detail that thirty minutes did not leave room for.

Everything here was tested against Next.js 16.3.5 and @sitecore-content-sdk/nextjs 2.4.0 on SitecoreAI, using webhook payloads captured from a real environment. The article is about the App Router only: Cache Components does not exist in the Pages Router.

The problem with caching by route

Most Sitecore heads cache whole pages. With incremental static regeneration (ISR), each page is cached by its path, and a webhook refreshes the path when something is published. That works well when the thing published is the page itself.

It works badly for a shared datasource. Say datasource D1 is used on 30 pages of a 10,000-page site. When D1 is published, the Experience Edge webhook says that D1 changed. It does not say which pages use it. To refresh those 30 pages by path, you first have to find them, which means calling Edge back in the middle of a publish, against its rate limit of 80 requests per second. The alternative is to mark the whole site stale with revalidatePath('/', 'layout') and let 9,970 pages be refetched for nothing. On the Pages Router it is worse, because res.revalidate(path) rebuilds each page immediately, whether or not anyone visits it.

Cache tags could have solved this, but before Cache Components they had to be declared up front, before the Sitecore call ran. At that point the only thing you know is the path:

// Next.js 15, stable: tags are fixed before the Sitecore call runs
const getCachedPage = (path: string[]) =>
  unstable_cache(
    () => client.getPage(path, { site, locale }),
    ['page', site, locale, ...path],
    { tags: [`path:/${path.join('/')}`] } // the path is all we know here
  )();

The page’s item ID and its datasource IDs only arrive in the response, too late to become tags. So tags could only be paths, while the webhook only speaks in item IDs.

Cache Components change the order. cacheTag() is called after the data has been read, so a page can tag itself with the item IDs it actually used. Those are the same IDs the webhook sends, so when D1 is published, revalidateTag finds exactly the 30 pages that use it, with nothing to look up. These APIs were experimental in Next.js 15 and became stable in Next.js 16.

How Cache Components work

Cache Components are switched on with cacheComponents: true in next.config.ts, which the Sitecore template does for you. From then on, nothing is cached unless you ask for it. Every data fetch, and every read of cookies, headers or search parameters, runs on every request unless it is inside a function marked 'use cache'. The old route settings, export const revalidate and export const dynamic, are no longer allowed.

Three functions then describe each cache: 'use cache' says what can be reused, cacheLife() says for how long, and cacheTag() says what it depends on.

'use cache': what can be reused

Put 'use cache' at the top of an async function and its result is stored and reused by later requests with the same arguments. A Sitecore page usually needs two kinds of data:

  • The same for every visitor until the next publish, such as the page layout, navigation and dictionary phrases. Cache these.
  • Different for each visitor, or live, such as a basket, a signed-in name or stock levels. Leave these uncached, and wrap the component that reads them in <Suspense>.

Next.js then sends the cached parts of the page straight away and streams the per-visitor parts in as they are ready. This is partial prerendering. Suspense only changes when a part of the page appears: the code inside it still runs on every request.

cacheLife: how long it lasts

async function getNavigation() {
  'use cache'
  cacheLife({
    stale: 300,       // 5 min: the browser reuses it on in-app navigation
    revalidate: 3600, // 1 hour: the server rebuilds it in the background
    expire: 86400,    // 1 day without a visitor: too old to serve
  })
  return loadNavigation()
}

revalidate and expire apply on the server. After an hour, the server still answers instantly from its cache, but that request starts a rebuild in the background, and the next request gets the new copy. This is ISR, applied to each cached function. If nobody asks for the entry for a day, it is thrown away and the next visitor waits for a fresh render.

stale applies in the visitor’s browser. Next.js keeps a copy of pages the visitor has already seen, and while that copy is fresh, following a <Link> to one of them makes no request at all. A refresh or a new visitor always goes to the server.

Webhooks are the fast way to update a cache. cacheLife is the safety net for a webhook that never arrives, or a dependency nobody tagged. Named profiles defined in next.config.ts, such as navigation or content, keep these policies easy to review.

cacheTag: what it depends on

A cached result can carry many tags. When content changes, revalidateTag(tag) invalidates every entry carrying that tag, and one matching tag is enough. Tags are exact, case-sensitive strings, and Next.js does not work them out for you: the code that caches the data and the code that handles the webhook have to build identical strings independently.

Revalidating a tag marks entries stale and renders nothing. With revalidateTag(tag, 'max'), which the Sitecore endpoint uses, the next visitor still gets the old version and triggers a rebuild, and the visitor after that gets the new one. Because nothing is rebuilt until someone visits it, marking 10,000 pages stale costs almost nothing up front.

What the Sitecore template gives you

The template arrived with Content SDK 2.2.0 on 30 June 2026 and is scaffolded with npx create-content-sdk-app@latest nextjs-app-router-cache-components. Its page decides between two audiences:

const page = (await draftMode()).isEnabled
  ? await client.getPreview(params)                // authors: drafts, never cached
  : await getSitecorePage({ site, locale, path }); // visitors: 'use cache' + tags

Visitors see published content, which changes only on publish and is safe to cache. Authors in Pages or preview see drafts that change constantly. That is why 'use cache' sits on the helper that performs the public read, and the page itself stays uncached.

The template also supplies cached dictionary and error-page helpers, tag builders, and a revalidation endpoint at /api/revalidate. Point an Experience Edge webhook at it and it turns each published item into revalidateTag calls. It switches off the SDK’s in-memory dictionary cache, for a reason we come back to below. Each cached page carries two tags: one for its route and one for its own item.

It sets no cacheLife on getSitecorePage, so pages get Next.js’s default profile: stale after 5 minutes in the browser, revalidated after 15 minutes on the server, and never expired. Without any webhook at all, a busy page is at most about 15 minutes out of date. Keep that number in mind, because it is what you fall back to whenever a webhook misses.

Publishing mode decides what the webhook can tell you

SitecoreAI can publish to Experience Edge in two modes, and they send different webhooks for the same change. We published a datasource, D1, used on two pages, A and B, and captured the payloads in each mode:

What was publishedSnapshot publishingEdge runtime publishing
Datasource D1, used on pages A and BD1, A, B, A-layout and B-layoutD1 only
Page AA-layout and AA-layout and A

Snapshot publishing stores each page on Edge as one assembled JSON document, with the datasource’s content copied in. When the datasource changes, every page holding that copy is out of date, so Sitecore republishes them, and the webhook names them. Edge runtime publishing stores a reference to D1 and assembles the page when it is queried, so only D1 is published. That is much faster to publish.

Sitecore’s documentation describes Edge runtime publishing as optimised for ISR and server-side rendering, which is how Cache Components renders. It lists the costs too: slower first delivery, static builds up to three times longer, and a 25 MB cap on rendered content. The template’s generateStaticPaths defaults to true, so out of the box it prerenders pages at build time, which is where that build cost lands. Choosing a mode is a trade-off, and Sitecore does not make a blanket recommendation.

Put that next to the template’s two tags. Under snapshot, a datasource publish also names the page, so the page’s item tag is revalidated. Under Edge runtime, it names only D1, the webhook handler builds a tag for D1, and no cached page carries it. Page A stays stale until its cacheLife runs out: up to about 15 minutes with the default profile.

Tag each page with its datasources

Simon Hauck showed the pattern in his SUGCON Europe 2026 session, Exploring Sitecore publishing: a helper that wraps the page read, extracts the content IDs from the layout and calls cacheTag with them, and a webhook route that revalidates each published identifier. He also recommends keeping a timer as a safety net. Sitecore’s template shipped ten weeks later with the same shape of helper and only the route and page-item tags.

Adding the datasources back is a small change to getSitecorePage:

export async function getSitecorePage({ site, locale, path }: Params) {
  'use cache'
  cacheLife('days') // explicit: webhooks are the fast path, this is the backstop

  const page = await client.getPage(path, { site, locale })
  const route = page?.layout?.sitecore?.route

  const tags = collectSitecorePageCacheTags({
    site,
    locale,
    path: client.parsePath(path),
    route,
  })
  tags.push(...(await getDatasourceTags(page, locale))) // the extension

  for (const tag of tags) cacheTag(tag)
  return page
}

getDatasourceTags walks the layout’s placeholders, including nested ones, and collects each rendering’s dataSource. Some datasources arrive as item IDs and some as paths. Paths are resolved to IDs with a small GraphQL query, itself wrapped in 'use cache' with cacheLife('max'), because an item’s ID does not change: the lookup runs once per path, not once per page. Each ID then becomes an item tag in exactly the format the webhook handler builds.

We ran this side by side with the template’s tags on two deployments, and replayed one captured Edge runtime payload for a datasource-only publish to both. The deployment without datasource tags stayed on the old content. The one with them served the old page to the next visitor and the new page to the one after, which is 'max' working as designed.

Two bugs to patch in Content SDK 2.4.0

Building the demos exposed two problems in 2.4.0. Both are fixed upstream and both are in the 2.5.0 canary. At the time of writing, 2.4.0 is still the latest stable release, so a new project scaffolded today has both.

The page tag and the webhook tag never match

For page A, the page side and the webhook side of 2.4.0 build these two tags:

page:    sc:item:115f8a09-91ec-4b8e-b131-024936d136a3:en:v1
webhook: sc:item:115f8a0991ec4b8eb131024936d136a3:en:latest

There are two differences, and either one is enough to miss. The layout gives a hyphenated item ID while Experience Edge sends a compact, upper-case one, and the SDK only lowercases and strips braces. The page side appends :v1 from the item version, and the webhook side always appends :latest. The failure is silent: the webhook returns 200, revalidateTag runs, and the page stays as it was.

Sitecore fixed both on 10 September 2026, a few hours after 2.4.0 was published: #619 removes the version segment and #620 hyphenates 32-character IDs on both sides. We had found the same problem independently on a client programme, and fixed it with a normaliser applied to the page tags and to the webhook identifiers before the SDK handler sees them.

Every publish refreshed every page

The webhook handler takes the list of sites so it can revalidate dictionaries. In 2.4.0, it adds a dictionary tag for every configured site to every call, whatever was published. Our environment had 14 sites, so a one-item publish revalidated 15 tags. Nearly every page reads the dictionary, so every page was marked stale.

This bug hides the datasource gap. On 2.4.0 as shipped, a datasource publish under Edge runtime appears to work, because everything refreshes. We found it when a webhook for an item that does not exist still refreshed page A. It was fixed on 14 September in #623, which revalidates a dictionary tag only for dictionary entry updates, and only for the site the entry belongs to.

Patch now, and plan the upgrade

Until 2.5.0 is stable, port the three fixes. Mark the port clearly, because it has an upgrade trap: 2.5.0 drops the version segment, so any tag string built by hand in the 2.4.0 format will silently stop matching after the upgrade. When you move to 2.5.0, delete the port and build datasource tags with the SDK’s own buildSitecoreItemCacheTag, so they follow whatever format the SDK uses.

Whatever version you run, check once that the two sides agree: log the tags a page is cached with, publish that page, and compare them with the tags the revalidation endpoint builds from the webhook. Both halves of the 2.4.0 template look correct when you read the code, and they still never match.

A page lives only as long as its shortest cache

This one surprised us. We set cacheLife('days') on getSitecorePage, and next build still reported a 15-minute revalidate on the catch-all Sitecore route. The dictionary and error-page helpers had no cacheLife, so they took the default profile, and a route lasts only as long as the shortest-lived cache it renders. Every page was held to 15 minutes by a dictionary read. Give every cached helper an explicit lifetime, and check the route summary in the build output after changing one.

Nested caches pass their tags up

'use cache' can go on a file, a component or any async function, and cached scopes can be nested. An inner scope’s tags are copied onto the entry around it, so revalidating an inner tag also invalidates the outer entry. Lifetimes are less generous: an inner cacheLife only reaches the outer scope when the outer scope has none of its own. The Next.js documentation recommends setting one on every scope, and we agree.

Cache the function that reads the data, and cache a component only when it fetches something of its own. Sitecore header and footer renderings get their fields from the layout, which getSitecorePage has already cached and tagged, so adding 'use cache' to Header.tsx caches the same data twice and gains nothing. Nesting is worth it when the inner piece has different data or a different lifetime.

One platform detail matters here. On Vercel, a page that goes stale because of any of its tags is regenerated with an on-demand revalidation render, and Next.js deliberately ignores existing 'use cache' entries during that render. Every cached function on the page runs again, including ones whose own tags were untouched. Nested caches save work on first renders and fresh instances, and they do not save calls to Experience Edge when a page is regenerated. That is also why the dictionary bug above re-read every page from Edge.

One cache layer per piece of data

The template turns off the SDK’s dictionary cache because it is a different kind of cache: phrases held in process memory for 60 seconds, keyed by site and language, and out of reach of revalidateTag. If a webhook clears the outer cache while the inner one still holds old phrases, the next render can store those old phrases in a new outer entry, where they stay until the next publish. Older Sitecore heads have the dictionary cache on by default, so check it when migrating. The same applies to force-cache, next.revalidate or next.tags on a fetch inside a 'use cache' helper: when two caches wrap the same data, the inner one decides what you see.

New items: the event the webhook cannot describe

A useful test for any tag is to walk through what can happen to the content. When it is read, can we attach a meaningful tag? When it is updated, does the event carry an ID we can turn into the same tag? When it is deleted, is there an event at all? Creating is the hard case, because a new item’s ID cannot already be a tag on anything cached before the item existed.

Datasource tags only cover what a page already uses. A cached product listing was built before product Z existed, so product Z cannot be one of its tags. The listing needs a list-level tag, such as products, and an event that can reach it. The Experience Edge webhook gives an item ID and an operation. Whether that item is a product, an article or a navigation link comes from its template or location, so the application has to look it up, usually with a query back to Edge, before it knows which list to invalidate. That lookup has a cost, so batch it where the API allows.

Precision is a choice

On-demand revalidation is not automatically better than a timer. Choose the cheapest strategy that meets the freshness the business needs:

StrategyHow it worksTrade-off
Event-drivenA tag per dependency, and a webhook that rebuilds the same tag. The datasource tags above are an example.Precise, when the event carries enough to rebuild the tag.
CoarseOne broad tag, such as a site tag, on everything in a scope, revalidated by any publish.Nothing needs classifying. With 'max' each page is rebuilt only on its next visit, at the cost of one Edge query per page visited.
Time-drivencacheLife alone, and no webhook.Staleness is bounded, and there is nothing to classify.
HybridTags for what you can identify, plus an explicit cacheLife sized to the freshness requirement.The lifetime covers everything the tags cannot.

Hybrid is the usual answer, and the template is already hybrid by accident, because the default profile revalidates after 15 minutes.

Scale adds one more consideration. A large publish can arrive as several webhook payloads. The SDK’s handler processes each one independently, which is safe because revalidating a tag twice is cheap with 'max', and that is usually enough. Correlating payloads to deduplicate across a whole publish needs state that outlives a single request, such as Redis, and brings expiry, concurrency, retries and idempotency with it. We would only take that on once the cost of the simple approach had been measured. We have not yet tested publishes large enough to be split, so treat the batching mechanics as something to confirm on your own environment. Whatever you choose, monitor the webhook itself: Experience Edge records each webhook’s status and last run, and a failing one can go unnoticed for a long time.

Where the cached result lives

Plain 'use cache' keeps results in the memory of each server instance. The Next.js documentation warns that in serverless environments memory is not shared between instances and is often discarded after a request, so work cached at request time gets few hits. 'use cache: remote' stores results in a cache handler shared by every instance, at the cost of storage and a network lookup on each read. Neither survives a deployment.

For Sitecore pages that are prerendered, the in-memory cache is fine, because the platform serves the rendered page and the entry matters mostly at build and rebuild time. On Vercel, pages first rendered on request are also cached at the CDN.

Where to start

Five questions, asked of every cache in a code or architecture review:

  1. What is the reusable boundary?
  2. How stale may it become?
  3. What does it depend on?
  4. What event can identify that dependency?
  5. What is the fallback when no event can?

Not having a reliable answer to question four is fine, as long as it changes the answer to question five.

For a new SitecoreAI build, start from the template. For an existing head on the Pages Router, move to the App Router first, then turn on cacheComponents and treat the build errors as the list of reads to sort into cached and per-visitor; the Next.js migration guide covers adopting it one route at a time. Either way, the same decisions follow:

  • Give getSitecorePage, the dictionary helper and the error-page helper an explicit cacheLife.
  • Add datasource tags if you publish with Edge runtime, or plan to.
  • On 2.4.0, patch the tag agreement and dictionary bugs, then publish a page and check that the tags it was cached with match the ones the endpoint builds.
  • Write down, for each cache, what it depends on, how stale it may get, and what happens when its event never arrives.

Cache Components give Sitecore teams better caching primitives. The work that remains is knowing what each page depends on, and what will tell you when that changes.

Work with a team that knows the detail

Stale pages after a publish, slow builds and heavy load on Experience Edge usually come down to details like the ones in this article. We have worked through them on client Sitecore programmes and can bring that experience to yours, whether you are starting a new build or getting an existing one back under control.

Exploring your options?

We’re here to help you think through what’s possible, at any stage of your project.

FAQs

Caching Sitecore with Cache Components FAQs