Sitecore XM Cloud + Vercel ISR - Part 2: The Datasource Gap
In Part 1 we wired an Experience Edge webhook to a Next.js revalidation endpoint on Vercel, so a publish in XM Cloud refreshes the affected pages in seconds. That webhook keys off page URLs - and that is exactly where it has a blind spot.
There is one common editor move it cannot see: publishing a shared datasource on its own. This post covers the gap, why closing it properly is harder than it looks, and the manual button that covers it in the meantime.
This is Part 2 of a three-part series:
- Part 1: Instant Publishes - the automatic path: webhook receiver, Vercel settings, Edge webhook.
- Part 2: The Datasource Gap (this post) - the workflow the automatic path cannot see, and the manual bridge.
- Part 3: The Secret - feeding the scripts a per-environment secret on XM Cloud.
Why a Published Datasource Does Not Refresh Its Pages
The automatic path keys off page URLs. But editors often edit a shared datasource (a Hero Banner's text or image) and publish only that datasource, not the page that shows it. Here is what happens:
- The editor edits the Hero Banner datasource in CM and publishes it.
- Edge fires the OnUpdate webhook with
entity_definition: Itemfor the datasource. - The handler tries to look up a URL for it. A datasource has no URL of its own.
- The handler logs "Skipping revalidation for undefined URL path" and moves on. No page cache is cleared.
- The homepage that uses the datasource keeps serving its cached HTML with the old content inside, until the background timer fires.
Why We Did Not Fully Automate This (Yet)
Closing the gap means a reverse-link lookup: given a datasource, find every page that references it, and revalidate each. We scoped it and deliberately left it for later, for three reasons:
- The query is not free. You need an Edge GraphQL query that, given a datasource id, returns the pages that reference it - a
referrers/linkedFromstyle traversal. That is more than the single-item lookup the current handler does, and it has to run for every datasource in a publish. - Fan-out and load. A shared datasource like a header or footer can be referenced by hundreds or thousands of pages. A naive "revalidate them all" would hammer Edge GraphQL and your functions - timeouts, cost, the lot - so it needs batching, de-duplication by path, and rate limiting before it is safe.
- Correctness is fiddly. Personalization and rendering variants, multisite, and the fact that the webhook fires per item (not per page) make it easy to over-revalidate (wasteful) or under-revalidate (stale). Getting it right needs careful de-duplication, and probably a cap or queue for the high-reference datasources.
None of this is impossible. The shape of a real implementation: a reverse-link GraphQL query, reuse of the existing batch logic for fan-out, de-duplication by final path, and a cap or background queue for datasources that touch many pages. It is a known, scoped piece of work, not a dead end. Until it ships, the manual bridge below covers the gap.
The On-Demand Endpoint
Alongside the webhook receiver from Part 1, we added an /api/admin/revalidate route that anything can call directly with a URL, a site name, and the shared secret. Its only job: turn a public URL and siteName into the multisite cache-key path (the same /_site_<sitename> rewrite trick from Part 1) and call res.revalidate:
// src/pages/api/admin/revalidate/index.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export interface RevalidateRequest {
url?: string;
secret?: string;
siteName?: string;
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
console.info('On Demand Revalidation is called');
const body = req.body as RevalidateRequest;
if (body.secret !== process.env.ISR_REVALIDATE_SECRET) {
console.info('Failed to revalidate: secret does not match');
return res.status(401).json({ revalidated: false, error: 'Invalid secret' });
}
try {
const pathToClear = body.url || '';
if (pathToClear === '') {
return res.status(400).json({ revalidated: false, error: 'No path provided' });
}
// Rewrite the public URL into the multisite-rewritten path Next.js uses as
// the ISR cache key: "/<locale>/_site_<sitename>/<rest>".
const segments = pathToClear.split('/').filter(Boolean);
const hasLocale = /^[a-z]{2}(-[a-z]{2})?$/i.test(segments[0] || '');
const localePrefix = hasLocale ? segments[0] : '';
const rest = hasLocale ? segments.slice(1) : segments;
const localeSegment = localePrefix ? `/${localePrefix}` : '';
const siteSegment = body.siteName ? `/_site_${body.siteName}` : '';
const restPath = rest.length ? '/' + rest.join('/') : '';
const structuredPath = `${localeSegment}${siteSegment}${restPath}` || '/';
console.info('structured path for revalidation:', structuredPath);
await res.revalidate(structuredPath);
return res.json({ revalidated: true, path: structuredPath });
} catch (err) {
console.error('error on revalidate', err);
return res
.status(500)
.json({ revalidated: false, error: err instanceof Error ? err.message : 'Unknown error' });
}
}The Manual Bridge: SPE Revalidate Page / Tree
Editors get two right-click options in Content Editor, Revalidate Page and Revalidate Tree, built with Sitecore PowerShell Extensions (SPE). Each script finds the site for the item, builds its public URL, and POSTs to the /api/admin/revalidate endpoint above, so the multisite cache key is cleared correctly.
Here is the full "Revalidate Page" script. The interesting parts: resolving which public site an item belongs to (multisite, so we match the longest site root path), mapping the CMS language to the frontend URL locale, and switching SiteContext so LinkManager builds the right URL:
function Get-PublicSiteForItem {
Param ([Sitecore.Data.Items.Item]$item)
$itemPath = $item.Paths.FullPath.ToLower()
$matchingSite = $null
$longestMatch = 0
foreach ($site in [Sitecore.Configuration.Factory]::GetSiteInfoList()) {
if ([string]::IsNullOrEmpty($site.RootPath) -or [string]::IsNullOrEmpty($site.TargetHostName)) { continue }
$rootPath = $site.RootPath.ToLower()
if ($itemPath.StartsWith($rootPath) -and $rootPath.Length -gt $longestMatch) {
$matchingSite = $site
$longestMatch = $rootPath.Length
}
}
return $matchingSite
}
function RevalidateISRForItem {
Param ([Sitecore.Data.Items.Item]$item)
# Map Sitecore CMS language code to frontend URL locale.
# Add entries here as new languages launch (e.g. "es-MX" = "es-mx").
# Unmapped languages fall back to the lowercase form of the CMS language code.
$languageToLocale = @{
"en" = "en-us"
"fr-CA" = "fr-ca"
}
$siteInfo = Get-PublicSiteForItem -item $item
if ($null -eq $siteInfo) {
Write-Host "[Vercel ISR] Could not resolve a public site for $($item.Paths.FullPath); skipping."
return
}
$scheme = $siteInfo.Scheme
if ([string]::IsNullOrEmpty($scheme)) { $scheme = "https" }
$siteBaseUrl = "$scheme`://$($siteInfo.TargetHostName)"
foreach ($lang in $item.Languages) {
$languageItem = Get-Item -Path "master:" -ID $item.ID -Language $lang
if ($languageItem.Versions.Count -eq 0) { continue }
if ($null -eq $languageItem.Visualization.Layout) { continue }
# Switch context to the public site so LinkManager builds the right URL
$siteContext = [Sitecore.Sites.SiteContext]::GetSite($siteInfo.Name)
if ($null -eq $siteContext) {
Write-Host "[Vercel ISR] Could not obtain SiteContext for $($siteInfo.Name); skipping language $($lang.Name)."
continue
}
$switcher = $null
try {
$switcher = New-Object Sitecore.Sites.SiteContextSwitcher $siteContext
$options = [Sitecore.Links.UrlOptions]::DefaultOptions
$options.AlwaysIncludeServerUrl = $false
$options.Language = $lang
$itemRelativeUrl = [Sitecore.Links.LinkManager]::GetItemUrl($languageItem, $options)
} finally {
if ($switcher) { $switcher.Dispose() }
}
# Resolve frontend locale; fall back to lowercase CMS language code.
$urlLocale = $languageToLocale[$lang.Name]
if (-not $urlLocale) { $urlLocale = $lang.Name.ToLower() }
# Normalize: strip protocol+host if absolute, leading slash, trailing slash.
if ($itemRelativeUrl.StartsWith($siteBaseUrl)) { $itemRelativeUrl = $itemRelativeUrl.Substring($siteBaseUrl.Length) }
if ($itemRelativeUrl.StartsWith("/")) { $itemRelativeUrl = $itemRelativeUrl.Substring(1) }
$itemRelativeUrl = $itemRelativeUrl.TrimEnd("/")
# Replace the first segment (Sitecore-emitted locale prefix) with the
# frontend locale so the URL matches what the Next.js app caches.
$segments = $itemRelativeUrl.Split("/", 2)
if ($segments.Length -gt 1) {
$itemRelativeUrl = "/$urlLocale/$($segments[1])"
} else {
$itemRelativeUrl = "/$urlLocale"
}
$body = @{
url = $itemRelativeUrl
secret = $secret
siteName = $siteInfo.Name
} | ConvertTo-Json
$headers = @{
"Content-Type" = "application/json"
"x-vercel-protection-bypass" = $bypassToken
}
$revalidatePostUrl = "$siteBaseUrl/api/admin/revalidate"
Write-Host "[Vercel ISR] site=$($siteInfo.Name) lang=$($lang.Name) url=$itemRelativeUrl"
try {
$response = Invoke-RestMethod -Uri $revalidatePostUrl -Method Post -Body $body -Headers $headers
Write-Host "[Vercel ISR] Response: $($response | ConvertTo-Json -Compress)"
} catch {
Write-Host "[Vercel ISR] Error: $($_.Exception.Message)"
}
}
}
# --- Vercel ISR config (per-environment) ------------------------------------
# The committed value is a PLACEHOLDER; the real per-environment secret is set
# as a database override of this script item in each CM. The full story of why
# is in Part 3 of this series.
$secret = "__SET_PER_ENVIRONMENT__" # = Vercel ISR_REVALIDATE_SECRET for this env
$bypassToken = "__SET_PER_ENVIRONMENT__" # = Vercel deployment-protection bypass; empty on PROD
if ([string]::IsNullOrWhiteSpace($secret) -or $secret -eq "__SET_PER_ENVIRONMENT__") {
Write-Host "[Vercel ISR] Secret is the committed placeholder - this CM's script was not overridden with the environment's real secret. Aborting."
return
}
$item = Get-Item -Path .
RevalidateISRForItem -item $item"Revalidate Tree" is the same script with one change: the function walks itself over every child item after processing the current one, so a whole branch of the tree is revalidated in one click:
# ...same per-item logic as Revalidate Page, then:
$children = Get-ChildItem -Path "master:" -ID $item.ID
foreach ($childItem in $children) {
RevalidateISRForItemRecursive -item $childItem
}You may have noticed the $secret placeholder and the slightly ominous comment above it. Getting a real per-environment secret into these scripts on XM Cloud turned out to be a whole story of its own - that is Part 3.
The Editor Workflow That Actually Works
1. Editor edits the Hero Banner datasource in CM.
2. Editor publishes the datasource <-- THIS STEP IS CRITICAL
-> Edge takes in the new content
-> Edge fires the OnUpdate webhook for the datasource Item
-> Webhook handler: no URL, so it is skipped (expected)
The homepage cache on Vercel still serves the OLD content here.
3. Editor right-clicks the affected page in Content Editor:
Scripts -> Vercel ISR -> Revalidate Page
-> SPE POSTs to /api/admin/revalidate with url="/en-us", siteName="<sitename>"
-> Vercel function calls res.revalidate("/en-us/_site_<sitename>")
-> the cached page is cleared
4. The next visitor loads /en-us
-> getStaticProps fetches the layout from Edge GraphQL
-> Edge returns the layout WITH the updated datasource
-> the page renders fresh and is cached againStep 2 is the trap. If the datasource is not published first, the Revalidate Page click looks like it worked (the response says revalidated: true, and the cache really is cleared), but the rebuild pulls the same old data Edge still has and caches the same content again. The button looks broken when it is not. Edge simply did not have the new data yet.
Revalidate Page vs Revalidate Tree
- Revalidate Page: when you know the exact page or pages affected. The cheapest option.
- Revalidate Tree: when you do not know which child pages use the changed datasource, or when a global element changes (a header or footer datasource). It walks every child page and revalidates each one. That is much more load, so prefer Revalidate Page when you can.
Wrapping Up
So: publishing a shared datasource on its own will not refresh its pages automatically yet, the proper fix is a known (if fiddly) piece of work, and editors have a manual button to lean on until it ships. Which brings us to the one thing every piece of this depends on and none of it should hardcode - the secret. That is Part 3, and it is where XM Cloud had the most surprises waiting.