📌 Scope: This post covers Optimizely CMS (SaaS) only — using the official @optimizely/cms-sdk and @optimizely/cms-cli packages with Next.js 15. If you’re on Optimizely CMS 13 (PaaS/DXP), the caching architecture and tooling are different. See the DXP ISR docs for that path.
Your editor hit Publish. Five minutes later the page still shows the old headline. Sound familiar?
This is the most common pain point after go-live on Optimizely SaaS CMS. The content is correct in the CMS — but something between the CMS and the browser is holding onto an old version. This post explains exactly what that something is, why there are three independent caches involved, and how to wire up webhooks using the content-js-sdk so your site updates within seconds of every publish.
What is Optimizely Graph?
Optimizely Graph is the GraphQL delivery API that sits between your CMS content and your Next.js front end. Instead of calling the CMS APIs directly, your app queries Graph — a hosted, indexed, search-optimised GraphQL service at cg.optimizely.com/content/v2.
When an editor publishes content, the CMS syncs it into Graph’s index. Your front end queries Graph to get the latest version. Simple in theory — but each step has its own cache, and each cache can serve stale data if not managed correctly.
The three cache layers
Every browser request passes through three independent caches. Understanding each one is the key to avoiding stale content.

Layer 1 — Optimizely Graph cache
The first cache lives inside Optimizely Graph itself. It caches GraphQL query responses with a TTL that’s tied to your content’s StopPublish date — if content expires at a known time, Graph’s cache purges automatically. On publish events, Graph invalidates the relevant cached responses within seconds.
Important: Graph cache eviction has no timing guarantee. The@optimizely/cms-sdk'sgetClient()is smart about this — it does not use the cached Graph response when resolving paths inside a webhook handler. This is one reason to use the SDK rather than raw fetch for webhook path resolution.
Layer 2 — Next.js ISR cache
The second cache is your Next.js app’s own ISR (Incremental Static Regeneration) cache. When you set export const revalidate = 60 on a page, Next.js caches the rendered HTML and serves it for up to 60 seconds. You invalidate this cache with revalidatePath().
On Vercel, ISR cache invalidation propagates automatically across all serverless instances — no shared cache backend needed. This is one of the big advantages of deploying to Vercel for an Optimizely SaaS project.
Self-hosted / multi-pod only: If you run Next.js on your own infrastructure with multiple pods, the default filesystem ISR cache is per-instance. You'll need a Redis-backed custom cache handler so that revalidatePath() propagates across all pods. This is not needed for Vercel or Netlify deployments.
Layer 3 — CDN / edge cache
The third cache is your CDN. On Vercel, calling revalidatePath() automatically purges Vercel’s Edge Network cache for that path — no extra step required. If you use a separate CDN (Cloudflare, Fastly, AWS CloudFront), you’ll need to call its purge API explicitly after revalidation.
Fixing it: on-demand revalidation with content-js-sdk
Time-based ISR (revalidate: 60) is a fallback, not a solution. For content that should be live within seconds of publishing, you need on-demand revalidation triggered by Optimizely Graph webhooks. The content-js-sdk ships a working sample for exactly this.

Webhook event types
| Event | When fired | What to do (SaaS CMS) |
|---|---|---|
doc.updated | A content item is published | Resolve item’s URL path via SDK, call revalidatePath(path) |
bulk.completed with deleted items | A sync job chunk included deletions | Call revalidatePath('/', 'layout') — no way to target specific deleted pages |
bulk.completed without deletions | Routine sync job | No action needed — doc.updated handles individual publishes |
Security: URL-based webhook ID
The content-js-sdk sample uses a clever security approach: the webhook URL itself contains a secret ID. Register the webhook at /webhooks/{WEBHOOK_ID} where WEBHOOK_ID is a long random string you generate. Anyone who doesn’t know the URL cannot trigger your revalidation endpoint. Set this in your environment:
# Generate a secure webhook ID (run once, save to env)node -e "console.log(require('crypto').randomUUID())"# Add to .env.localWEBHOOK_ID="your-generated-uuid-here"
The webhook handler — using content-js-sdk
Create src/app/webhooks/[id]/route.ts. The handler validates the URL-based ID, then uses getClient() from @optimizely/cms-sdk to resolve the content path — no manual raw-fetch or Graph cache bypass needed:
// src/app/webhooks/[id]/route.ts// Source: github.com/episerver/content-js-sdk — graph-webhooks-cache-invalidation sampleimport { getClient } from '@optimizely/cms-sdk'import { revalidatePath } from 'next/cache'import { notFound } from 'next/navigation'// Security: the webhook URL is the secret.// Anyone with the URL can trigger cache revalidation — treat WEBHOOK_ID like a password.const WEBHOOK_ID = process.env.WEBHOOK_ID!/** Given a docId, resolve the path and revalidate it */async function revalidateDocId(docId: string) { // docId format: {UUID}_{language}_{status} e.g. "abc123_en_Published" const parts = docId.split('_') const id = parts[0].replaceAll('-', '') const locale = parts[1] // e.g. "en" const client = getClient() // uses @optimizely/cms-sdk — handles Graph client setup const response = await client.request(` query GetPath($id: String, $locale: Locales) { _Content(ids: [$id], locale: [$locale]) { item { _id _metadata { url { default } } } } } `, { id, locale }) const raw = response._Content.item._metadata.url.default const path = raw.endsWith('/') ? raw.slice(0, -1) : raw revalidatePath(path) console.log('Revalidated path: %s', path)}export async function POST( request: Request, { params }: { params: Promise<{ id: string }> }) { const webhookId = (await params).id // Validate the URL-based secret if (webhookId !== WEBHOOK_ID) { notFound() } const body = await request.json() if (body.type.subject === 'bulk' && body.type.action === 'completed') { const deleted = Object.values(body.data.items ?? {}).find(s => s === 'deleted') // Only do a full revalidation if content was deleted — // there's no way to know which page was deleted, so revalidate everything if (deleted) { revalidatePath('/', 'layout') } } else if (body.type.subject === 'doc' && body.type.action === 'updated') { await revalidateDocId(body.data.docId) } return Response.json({ message: 'OK' })}
Register the webhook in Optimizely CMS
Register the webhook URL in your CMS once your app is deployed. In CMS → Settings → Webhooks → Add Webhook:
- URL:
https://your-site.vercel.app/webhooks/{WEBHOOK_ID} - Method: POST
- Events: All (
*.*) or narrow todoc.*andbulk.*
Alternatively, auto-register the webhook on app startup using Next.js’s instrumentation.ts. The content-js-sdk repo has a full sample showing this pattern.
Enable ISR on your pages
Set a revalidation interval as a safety net — webhooks handle instant updates, but this ensures pages self-heal even if a webhook is missed:
// app/[...slug]/page.tsxexport const revalidate = 60 // fallback: revalidate every 60sexport const dynamic = 'force-static' // ensure pages are cached after first renderexport default async function Page({ params }) { const client = getClient() const page = await client.getContentByUrl(params.slug.join('/'), { locale: 'en' }) return <YourPageComponent page={page} />}
Environment variables
# .env.local — Optimizely SaaS CMS + Next.jsOPTIMIZELY_CMS_URL="https://app-{your-instance}.cms.optimizely.com"OPTIMIZELY_GRAPH_GATEWAY="https://cg.optimizely.com"OPTIMIZELY_GRAPH_SINGLE_KEY="" # Public read key — CMS → Settings → API KeysOPTIMIZELY_GRAPH_APP_KEY="" # App key — for SDK client authOPTIMIZELY_GRAPH_SECRET="" # App secret# Webhook securityWEBHOOK_ID="your-generated-uuid" # The secret segment in /webhooks/{WEBHOOK_ID}
Performance: avoid deep GraphQL nesting
Webhooks fix staleness — but a poorly modeled content tree makes every Graph query slow regardless. The biggest culprit is deep nesting.
When Graph serializes a response, it traverses every level of your content hierarchy. A query that goes Page → Section → Card → SubCard → Tag forces Graph to resolve 4+ joins per page. At scale this creates a serialization bottleneck — slow queries and slow ISR regeneration.
| Anti-pattern | Problem | Fix |
|---|---|---|
| Content area embeds 10+ child items inline | Every child serialized on every page query | Use content references; fetch child content in separate queries |
| More than 3 levels of nesting | Exponential serialization cost | Flatten the model — make blocks independent types with reference fields |
| Recursive queries without depth limit | Can loop indefinitely, Graph timeout | Use @recursive directive with max depth, or fetch trees separately |
The rule of thumb: max 2–3 levels of nesting. Design shared components (Hero, CTA, Card) as independent content types connected by content references — not inline content areas.
Monitoring and debugging
Graph playground
Test queries directly at: https://cg.optimizely.com/content/v2?auth={SINGLE_KEY}. Use it to verify content types are indexed after a CLI push, check schema structure, and measure query response time. Essential for diagnosing why a content type isn’t showing up.
Graph reindex after CLI push
Any time you push new or updated content type definitions with @optimizely/cms-cli, the Graph schema won’t update until you reindex: CMS → Settings → Scheduled Jobs → Graph Reindex. Without this, new fields won’t appear in GraphQL and your SDK queries will return empty results for new content types.
Check webhook delivery
Optimizely Graph does not retry failed webhooks indefinitely. Add console.log to your handler for every incoming request and every successful revalidation. On Vercel, the Functions tab shows real-time invocation logs — check for 404s (wrong WEBHOOK_ID) or 500s (handler errors).
Common issues and fixes
| Symptom | Root cause | Fix |
|---|---|---|
| Page still stale after webhook fires | Webhook is hitting wrong URL or WEBHOOK_ID mismatch | Check Vercel function logs for 404; verify WEBHOOK_ID env var matches registered URL |
| Webhook not firing at all | Webhook not registered in CMS, or wrong URL | CMS → Settings → Webhooks — verify URL and that webhook is enabled |
| Content not in Graph after CLI push | Graph schema not reindexed | CMS → Settings → Scheduled Jobs → Graph Reindex |
getClient() throws — missing config | SDK env vars not set | Ensure OPTIMIZELY_CMS_URL, OPTIMIZELY_GRAPH_SINGLE_KEY, OPTIMIZELY_GRAPH_APP_KEY, OPTIMIZELY_GRAPH_SECRET are all set |
| Deleted content page still reachable | bulk.completed without deleted check not doing full revalidation | Handle the deleted check inside bulk.completed as shown in the SDK sample |
| Stale content on non-Vercel CDN | CDN not purged after revalidatePath() | Call your CDN’s purge API (Cloudflare/Fastly) after revalidation — not needed on Vercel |
Stale content checklist (SaaS CMS + Vercel)
- ☐
WEBHOOK_IDgenerated and set in Vercel environment variables - ☐ Webhook handler at
app/webhooks/[id]/route.tsusinggetClient()from@optimizely/cms-sdk - ☐
doc.updated→revalidatePath(resolvedPath) - ☐
bulk.completedwith deleted items →revalidatePath('/', 'layout') - ☐ Webhook registered in CMS Settings → Webhooks with correct URL
- ☐
export const revalidate = 60set on pages as fallback - ☐ Graph reindexed after every
cms-cli push - ☐ Content type nesting depth ≤ 3 levels
- ☐ (Self-hosted only) Redis cache handler configured for multi-pod ISR
- ☐ (Non-Vercel CDN only) CDN purge API wired up after revalidation