Understanding Optimizely Graph: Caching, Webhooks & Avoiding Stale Content (Optimizely SaaS CMS)

📌 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's getClient() 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

EventWhen firedWhat to do (SaaS CMS)
doc.updatedA content item is publishedResolve item’s URL path via SDK, call revalidatePath(path)
bulk.completed with deleted itemsA sync job chunk included deletionsCall revalidatePath('/', 'layout') — no way to target specific deleted pages
bulk.completed without deletionsRoutine sync jobNo 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.local
WEBHOOK_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 sample
import { 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 to doc.* and bulk.*

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.tsx
export const revalidate = 60 // fallback: revalidate every 60s
export const dynamic = 'force-static' // ensure pages are cached after first render
export 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.js
OPTIMIZELY_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 Keys
OPTIMIZELY_GRAPH_APP_KEY="" # App key — for SDK client auth
OPTIMIZELY_GRAPH_SECRET="" # App secret
# Webhook security
WEBHOOK_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-patternProblemFix
Content area embeds 10+ child items inlineEvery child serialized on every page queryUse content references; fetch child content in separate queries
More than 3 levels of nestingExponential serialization costFlatten the model — make blocks independent types with reference fields
Recursive queries without depth limitCan loop indefinitely, Graph timeoutUse @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

SymptomRoot causeFix
Page still stale after webhook firesWebhook is hitting wrong URL or WEBHOOK_ID mismatchCheck Vercel function logs for 404; verify WEBHOOK_ID env var matches registered URL
Webhook not firing at allWebhook not registered in CMS, or wrong URLCMS → Settings → Webhooks — verify URL and that webhook is enabled
Content not in Graph after CLI pushGraph schema not reindexedCMS → Settings → Scheduled Jobs → Graph Reindex
getClient() throws — missing configSDK env vars not setEnsure OPTIMIZELY_CMS_URL, OPTIMIZELY_GRAPH_SINGLE_KEY, OPTIMIZELY_GRAPH_APP_KEY, OPTIMIZELY_GRAPH_SECRET are all set
Deleted content page still reachablebulk.completed without deleted check not doing full revalidationHandle the deleted check inside bulk.completed as shown in the SDK sample
Stale content on non-Vercel CDNCDN 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_ID generated and set in Vercel environment variables
  • ☐ Webhook handler at app/webhooks/[id]/route.ts using getClient() from @optimizely/cms-sdk
  • doc.updatedrevalidatePath(resolvedPath)
  • bulk.completed with deleted items → revalidatePath('/', 'layout')
  • ☐ Webhook registered in CMS Settings → Webhooks with correct URL
  • export const revalidate = 60 set 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

Resources

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.