Sitecore XM Cloud + Vercel ISR - Part 1: Instant Publishes
Every editor on a headless Sitecore project eventually asks the same question: "I published - why is the live site still showing the old content?"
If you run a Next.js site on Vercel in front of Sitecore XM Cloud, you have two unattractive options out of the box. Rebuild the whole site on every publish (slow and wasteful), or put every page on a short ISR timer (constant load on Experience Edge, and editors still wait). On a recent multi-brand XM Cloud project, my team wired up a third option: a publish in XM Cloud triggers an immediate, targeted refresh of just the affected pages on Vercel, while everything else sits on a long, lazy background timer.
This series documents that setup, including the traps that cost us real hours. Each part stands alone, so jump to whichever problem you are staring at:
- Part 1: Instant Publishes (this post) - the core path: a Next.js webhook receiver, the Vercel settings, and the Experience Edge webhook that calls it.
- Part 2: The Datasource Gap - the one editor workflow the automatic path cannot see, and the manual "Revalidate Page / Tree" buttons that bridge it.
- Part 3: The Secret - four ways to feed a per-environment secret to a Sitecore PowerShell script, three of which die on XM Cloud.
You will get the most from this if you have worked with a Next.js site on Vercel and a Sitecore XM Cloud back end, but you do not need to be deep in either.
A quick glossary
A few terms come up throughout, in case any are new:
- ISR (Incremental Static Regeneration): Next.js serves a pre-built (cached) page and refreshes it on a timer or on demand, instead of rebuilding the whole site.
- Revalidate: throw away the cached copy of a page so the next request rebuilds it with fresh data.
- Experience Edge: Sitecore's content delivery layer - your Next.js app reads published content from it over GraphQL.
- CM: the Content Management server, where editors author and publish.
- Datasource: the content item a component on a page points at (say, the text and image behind a Hero Banner).
The Architecture
Here is the target: a publish in XM Cloud refreshes the affected pages on Vercel within seconds, while the rest of the site coasts on a long background timer so Edge and your serverless functions stay quiet.
The whole thing in one breath - set a long ISR timer you read from an env var, add a small webhook route to your Next.js app that clears the cache for changed pages, and point a Sitecore Experience Edge webhook at that route on every publish. Everything below is just the detail.
Editor publishes in XM Cloud
|
v
Experience Edge processes the publish
|
v
Edge fires OnUpdate webhook --HTTPS POST + 'secret' header--> Vercel /api/admin/webhook
|
v
For each changed page:
- look up its URL via GraphQL
- call res.revalidate(path)
|
v
The cached page is cleared;
the next request rebuilds itPages that were not part of a publish keep using the high background revalidate timer (an hour in our case). Pages that a publish touched refresh right away. The result is low load on Edge, plus fast feedback for editors.
Step 1: Read the Timer from an Environment Variable
Replace any hardcoded revalidate: <n> in getStaticProps (the Next.js function that fetches a page's data when it is built or refreshed) so every route reads one value:
const revalidateTimer = !!process.env.ISR_REVALIDATE_TIMER
? parseInt(process.env.ISR_REVALIDATE_TIMER)
: 300;
return { props, revalidate: revalidateTimer, notFound: props.notFound };Check every route under src/pages/. Product and category pages often keep their own copy, and any one you miss quietly ignores the env var.
Step 2: Add the Webhook Receiver
Add an /api/admin/webhook route. It does four things: only allow POST, check the on/off toggle, check the secret header, then return 200 right away and do the slow work in the background with waitUntil (so Edge does not give up and retry mid-revalidate). The background job resolves each changed item to a URL via GraphQL and calls res.revalidate for it.
First, the payload Experience Edge sends in OnUpdate mode, reduced to the types we need:
// The payload Experience Edge sends in OnUpdate mode.
export interface WebhookRequestBody {
invocation_id: string;
updates: Array<{
identifier: string; // item GUID (LayoutData ids look like "<guid>-layout")
entity_definition: 'LayoutData' | 'Item' | string;
entity_culture: string; // e.g. "en"
operation?: string;
}>;
continues: boolean;
}
// A change reduced to just what we need to resolve a URL.
export interface SitecoreItemUrl {
identifier: string;
entity_culture: string;
}
// The result of looking an item up in Edge GraphQL.
export interface TSitecoreItemQueryResult {
url?: {
path: string; // public path incl. locale, e.g. "/en-us/products/foo"
siteName?: string; // present in multisite setups
};
language?: string;
}The receiver itself:
// src/pages/api/admin/webhook/index.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { waitUntil } from '@vercel/functions';
import { RevalidationService } from 'lib/webhook/revalidate/revalidate-service';
import { fetchItemUrl } from 'lib/webhook/revalidate/helpers';
import type { WebhookRequestBody } from 'lib/webhook/revalidate/types';
const BATCH_SIZE = Number(process.env.PROCESS_BATCH_SIZE || '25');
async function processRevalidationBatches(
updates: WebhookRequestBody['updates'],
revalidationService: RevalidationService,
res: NextApiResponse
) {
try {
const layoutUpdates = revalidationService.processLayoutUpdates(updates);
// Resolve each changed item to a URL via GraphQL (datasources resolve to
// nothing and drop out here).
const urls = await Promise.all(
layoutUpdates.map(async ({ identifier, entity_culture }) => {
try {
return await fetchItemUrl(identifier.replace('-layout', ''), entity_culture);
} catch (error) {
console.log(`Failed to fetch item URL: ${error}`);
return null;
}
})
);
const validUrls = urls.filter((url): url is NonNullable<typeof url> => url !== null);
for (let i = 0; i < validUrls.length; i += BATCH_SIZE) {
const batch = validUrls.slice(i, i + BATCH_SIZE);
try {
await revalidationService.revalidateUrls(batch, res);
} catch (error) {
console.error(`Error processing batch ${Math.floor(i / BATCH_SIZE) + 1}:`, error);
}
}
console.log('Revalidation completed successfully');
} catch (error) {
console.error('Revalidation process failed:', error);
}
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
res.setHeader('Allow', ['POST']);
return res.status(405).json({ revalidated: false, error: `Method ${req.method} Not Allowed` });
}
const revalidationService = new RevalidationService(process.env.ISR_REVALIDATE_SECRET || '');
try {
if (!revalidationService.isWebhookEnabled()) {
return res.status(200).json({ revalidated: false, error: 'Webhook processing is disabled' });
}
if (!revalidationService.validateSecret(req.headers.secret as string | undefined)) {
return res.status(401).json({ revalidated: false, error: 'Unauthorized' });
}
const { updates } = req.body as WebhookRequestBody;
// Fire-and-forget; we return 200 immediately below.
waitUntil(processRevalidationBatches(updates, revalidationService, res));
return res.status(200).json({ revalidated: true });
} catch (error) {
console.log('Webhook processing failed:', error);
// Still 200 to avoid retries; report the failure in the body + logs.
return res.status(200).json({ revalidated: false, error: 'Webhook processed unsuccessfully' });
}
}And the service that turns Edge "something changed" events into res.revalidate calls. One detail here is easy to get wrong in a multisite setup, and it cost us a confusing afternoon: Sitecore's middleware rewrites an incoming request to insert a /_site_<sitename> segment after the locale, and ISR caches the page under that rewritten path. So you must revalidate the rewritten form, not the public URL:
"/en-us/products/foo" -> "/en-us/_site_<sitename>/products/foo"If you revalidate the public URL instead, the cache key never matches and the page looks like it was never cleared.
// lib/webhook/revalidate/revalidate-service.ts
import { NextApiResponse } from 'next';
import { revalidate } from './helpers';
import type { SitecoreItemUrl, TSitecoreItemQueryResult, WebhookRequestBody } from './types';
export class RevalidationService {
private readonly secret: string;
private readonly webhookEnabled: boolean;
constructor(secret: string) {
this.secret = secret;
this.webhookEnabled = process.env.Enable_OnUpdate_Webhook === 'true';
}
isWebhookEnabled(): boolean {
return this.webhookEnabled;
}
validateSecret(requestSecret?: string): boolean {
return requestSecret === this.secret;
}
processLayoutUpdates(updates: WebhookRequestBody['updates']): SitecoreItemUrl[] {
// Accept both LayoutData (page composition changes) and Item (page field /
// datasource changes). Items with no URL of their own (typical datasources)
// resolve to empty downstream and are skipped. Duplicate LayoutData+Item
// events for the same item are harmless: revalidate is idempotent.
return updates
?.filter((u) => u.entity_definition === 'LayoutData' || u.entity_definition === 'Item')
?.map(({ identifier, entity_culture }) => ({ identifier, entity_culture }));
}
async revalidateUrls(urls: Array<TSitecoreItemQueryResult>, res: NextApiResponse): Promise<void> {
for (const url of urls) {
if (!url?.url?.path) {
console.log('Skipping revalidation for undefined URL path');
continue;
}
try {
const pathToClear = this.buildPathToClear(url);
await revalidate(res, pathToClear);
console.log(`Successfully revalidated: ${pathToClear}`);
} catch (error) {
console.log(`Failed to revalidate path: ${error}`);
}
}
}
private buildPathToClear(url: TSitecoreItemQueryResult): string {
// url.url.path is the public path including locale (e.g. "/en-us/products/foo").
// Rewrite it to the multisite cache-key form: "/<locale>/_site_<sitename>/<rest>".
const publicPath = (url.url?.path ?? '').replace(/^https?:\/\/[^/]+/, '').replace(/\/+$/, '');
const siteName = url.url?.siteName;
if (!siteName) {
return publicPath || '/';
}
const match = publicPath.match(/^(\/[^/]+)(.*)$/);
if (match) {
return `${match[1]}/_site_${siteName}${match[2]}`;
}
return `/_site_${siteName}${publicPath}`;
}
}The two helpers it leans on are small: fetchItemUrl runs your Edge GraphQL query for an item's url { path siteName } and language fields (swap in whatever GraphQL client your project already uses), and revalidate is a thin wrapper around res.revalidate that never throws, so a single bad path cannot abort a batch:
export async function revalidate(res: NextApiResponse, pathToClear: string): Promise<boolean> {
try {
await res.revalidate(pathToClear);
return true;
} catch (err) {
console.info('error on revalidate', err);
return false;
}
}Step 3: Vercel Environment Variables
Set these on the project (one project per environment in our setup: dev, uat, prod):
| Variable | Value | Type |
|---|---|---|
ISR_REVALIDATE_TIMER | 3600 (1 hour) | Plain |
ISR_REVALIDATE_SECRET | Long random string (64+ hex characters) | Sensitive |
Enable_OnUpdate_Webhook | true (the literal string) | Plain |
PROCESS_BATCH_SIZE | Optional - webhook fan-out batch size (default 25) | Plain |
A changed variable only takes effect on a new deployment, so set them and then redeploy. If you prefer scripting it over clicking through the dashboard:
$token = "<vercel-personal-token>"
$projectId = "<prj_...>"
$teamId = "<team_...>"
$headers = @{ Authorization = "Bearer $token"; "Content-Type" = "application/json" }
# Use upsert=true so re-running updates instead of erroring. Mark the secret
# "sensitive" (write-only) rather than "encrypted" (readable back via API).
$vars = @(
@{ key = "ISR_REVALIDATE_TIMER"; value = "3600"; type = "plain" },
@{ key = "ISR_REVALIDATE_SECRET"; value = "<long-random-string>"; type = "sensitive" },
@{ key = "Enable_OnUpdate_Webhook"; value = "true"; type = "plain" }
)
foreach ($v in $vars) {
$body = @{ key = $v.key; value = $v.value; type = $v.type; target = @("development","preview","production") } | ConvertTo-Json
Invoke-RestMethod -Method Post `
-Uri "https://api.vercel.com/v10/projects/$projectId/env?teamId=$teamId&upsert=true" `
-Headers $headers -Body $body | Out-Null
Write-Host "set $($v.key)"
}
# Redeploy the latest production deployment so the new values bake in
$latest = (Invoke-RestMethod -Headers $headers `
-Uri "https://api.vercel.com/v6/deployments?teamId=$teamId&projectId=$projectId&target=production&limit=1").deployments[0].uid
$redeploy = @{ deploymentId = $latest; name = "<project-name>"; target = "production" } | ConvertTo-Json
Invoke-RestMethod -Method Post `
-Uri "https://api.vercel.com/v13/deployments?teamId=$teamId&forceNew=1" `
-Headers $headers -Body $redeploy | Out-NullStep 4: Create the Experience Edge Webhook
Get an "Edge Administration Client" from the XM Cloud Deploy portal (your environment, then the Credentials tab). It gives you a client_id and client_secret, scoped to one environment. Then get a token and register a webhook that POSTs to your route with the same secret as a custom header.
The trap that ate an hour: the OAuth token request body must be form-urlencoded, not JSON. Both content types are accepted and both return a token with identical claims when you decode it, but only the form-urlencoded one works against Edge. The JSON one fails later with:
401 Unauthorized
edge.cdn.11: Unauthorized. Failed to find tenant.Here is the full registration script, working token call included:
$clientId = "<edge-admin-client-id>"
$clientSecret = "<edge-admin-client-secret>"
$secret = "<same value as the Vercel ISR_REVALIDATE_SECRET>"
$host_ = "example.com" # your deployed host for this environment
# --- Get a token (form-urlencoded, NOT JSON) ---------------------------------
$enc = [System.Uri]::EscapeDataString($clientSecret)
$formBody = "audience=https%3A%2F%2Fapi.sitecorecloud.io&grant_type=client_credentials&client_id=$clientId&client_secret=$enc"
$tok = (Invoke-RestMethod -Method Post -Uri "https://auth.sitecorecloud.io/oauth/token" `
-Headers @{ "Content-Type" = "application/x-www-form-urlencoded" } -Body $formBody).access_token
$auth = @{ Authorization = "Bearer $tok"; "Content-Type" = "application/json" }
# --- Create the webhook -------------------------------------------------------
$createBody = @{
label = "Revalidate (your-env-name)"
uri = "https://$host_/api/admin/webhook"
method = "POST"
headers = @{ secret = $secret } # add x-vercel-protection-bypass below if protected
executionMode = "OnUpdate"
createdBy = "your-name"
} | ConvertTo-Json -Compress
$created = Invoke-RestMethod -Method Post -Uri "https://edge.sitecorecloud.io/api/admin/v1/webhooks" -Headers $auth -Body $createBody
Write-Host "created webhook id: $($created.id)" # save this id
# --- List ---------------------------------------------------------------------
Invoke-RestMethod -Uri "https://edge.sitecorecloud.io/api/admin/v1/webhooks" -Headers @{ Authorization = "Bearer $tok" }
# --- Update (e.g. add the Deployment Protection bypass header, re-enable) ------
$webhookId = "<id from create>"
$updateBody = @{
label = "Revalidate (your-env-name)"
uri = "https://$host_/api/admin/webhook"
method = "POST"
headers = @{ secret = $secret; "x-vercel-protection-bypass" = "<vercel-bypass-token>" }
executionMode = "OnUpdate"
createdBy = "your-name"
disabled = $false
} | ConvertTo-Json -Compress
Invoke-RestMethod -Method Put -Uri "https://edge.sitecorecloud.io/api/admin/v1/webhooks/$webhookId" -Headers $auth -Body $updateBody | Out-NullWhy OnUpdate and not OnEnd?
Experience Edge offers two webhook modes. OnEnd fires once per publish job with no detail about what changed - you would need a follow-up query to find out. OnUpdate fires with the list of changed items (and their language) right in the body, in batches if needed. OnUpdate is the right fit when you want to revalidate specific pages; OnEnd fits a blunt "rebuild everything" or "warm an index" trigger.
Step 5: Verify End to End
- Publish a page change in XM Cloud.
- Watch the Vercel runtime logs. You should see the revalidation messages, ending in
Successfully revalidated: /<lang>/<path>. - Load that page on the deployed site. The change should appear right away, not after the timer.
To check the background timer, do not look for s-maxage in the response headers. Vercel does not expose your timer value to the browser - every ISR page sends the same generic cache-control header. What you actually watch is the cache status:
| Header | What it means |
|---|---|
cache-control: public, max-age=0, must-revalidate | For the browser. The same on every ISR page. Not a sign of your timer value. |
x-vercel-cache: HIT / MISS / STALE / REVALIDATED | The one to watch. MISS = just built. HIT = from cache. STALE = served from cache while a background rebuild runs (the sign the timer elapsed). |
age: <seconds> | Time since Vercel cached this response. Tops out near the timer. |
x-matched-path | The route that served the response. Confirms you hit the route you expected. |
The only place the actual timer number appears is the build output. Open the Vercel deployment inspector, go to Build Logs, and find the route table near the end. Each ISR route shows a "Revalidate" column. If a route still shows the old number, you missed a hardcoded revalidate: in step 1.
The Gotchas
A few things that are not obvious until they bite:
- The handler returns 200 even when it fails internally. This is on purpose, so Edge does not retry and eventually auto-disable the webhook. But it means a "success" in the Sitecore dashboard does not prove a revalidate happened. The Vercel logs are the source of truth.
- One webhook per Edge environment. The Admin API client works for one environment, so you repeat the registration once per environment.
Diagnosing a 401 from the Edge Webhook
If every lastRuns entry shows Status: Unauthorized and the Vercel logs for the route are empty, the request is being blocked by Vercel Deployment Protection at the CDN, before it ever reaches your function. To confirm, load the host with no headers: a 401 with Content-Type: text/html and an "Authentication Required" body is Vercel's protection page, not your handler.
The fix: add the project's Protection Bypass token (Vercel project, Settings, Deployment Protection) as an x-vercel-protection-bypass header on the webhook, and set disabled: false in the same request to switch it back on if it was auto-disabled. The update call is in the registration script above.
Diagnosing "Failed to find tenant"
If you see 401 edge.cdn.11: Unauthorized. Failed to find tenant., decode the token and confirm it has tenant_id, tenant_name, and the edge.webhooks:create scope. If those are there, you almost certainly sent the token request as JSON. Redo it as form-urlencoded (see step 4).
Wrapping Up
That is the core loop: publishes refresh the right pages in seconds, the background timer is just a lazy fallback, and the logs and headers tell you whether each path actually fired. Which leaves exactly one editor workflow the automatic path still cannot see - publishing a shared datasource on its own. That is the subject of Part 2.
Hopefully this saves you the afternoon we spent staring at cache headers!