# 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](/posts/xm-cloud-vercel-isr-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](/posts/xm-cloud-vercel-isr-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.

```text
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 it
```

Pages 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:

```ts
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:

```ts
// 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:

```ts
// 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:

```text
"/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.

```ts
// 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:

```ts
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:

```powershell
$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-Null
```

### Step 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:

```text
401 Unauthorized
edge.cdn.11: Unauthorized. Failed to find tenant.
```

Here is the full registration script, working token call included:

```powershell
$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-Null
```

#### Why 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

1. Publish a page change in XM Cloud.
2. Watch the Vercel runtime logs. You should see the revalidation messages, ending in `Successfully revalidated: /<lang>/<path>`.
3. 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](/posts/xm-cloud-vercel-isr-part-2-the-datasource-gap).

Hopefully this saves you the afternoon we spent staring at cache headers!
