Variation #2Continue reading...

Developer Demo

ISR Caching & Webhooks

Optimizely Graph + Next.js ISR gives you static-site performance with CMS-speed updates. Pages are pre-rendered at build time and regenerated in the background whenever content changes — no redeploy required.

This page revalidates every 30sNext.js ISRrevalidatePath · revalidateTag3 webhook endpoints

Live Proof of ISR #

This page is server-rendered with export const revalidate = 30. The timestamp below is stamped by the server at render time — it only changes when Next.js regenerates the page in the background (after 30 seconds of staleness). Hard-refreshing serves the cached version; the timestamp stays the same until the background regeneration completes.

Last rendered by server
2026-06-07T11:42:18.603Z

Caching Strategy #

Every data source in the project has an explicit caching policy. TTLs are tuned to the update frequency of each piece of data — navigation changes rarely so it caches for 5 minutes; banners and content change more often so they cache for 60 seconds. Search is always fresh.

DataLocationTTLCache tagRevalidated by
CMS page contentgetClient().getContentByPath()60s/api/revalidate or /api/webhooks
Navigation treegetNavigation()300s (5 min)navigationrevalidateTag('navigation') in /api/webhooks
Site bannergetSiteBanner()60sbannerrevalidateTag('banner') — manual
External referralsgetReferrals()60sreferralsrevalidateTag('referrals') — manual
Page metadatagenerateMetadata()300s (5 min)/api/revalidate
Static page pathsgenerateStaticParams()3600s (1 hr)Next.js build / deploy
FX datafilebuildClient() in experimentation.ts60sAutomatic (fetch cache)
Search resultsGET /api/searchno-storeAlways fresh — bypasses ISR
Draft/previewclient.getPreviewContent()no-storeAlways fresh — bypasses ISR
Graph CDN cachecg.optimizely.com/content/v2Graph-managed?cache=false on the request URL — see section below

The graphqlFetch Pattern #

All GraphQL requests go through a single graphqlFetch() helper in src/lib/optimizely/client.ts. It applies caching automatically based on context — published ISR by default, no-store for draft/preview, and caller-overridable for fine-grained control.

Core helper (caching logic)

// src/lib/optimizely/client.ts

export async function graphqlFetch<T>(
  query: string,
  variables?: Record<string, unknown>,
  options: GraphQLRequestOptions = {}
): Promise<GraphQLResponse<T>> {
  const { previewToken, next, cache } = options;

  const fetchOptions: RequestInit = { method: "POST", headers, body };

  if (cache) {
    fetchOptions.cache = cache;               // explicit override (e.g. "no-store")
  } else if (next) {
    fetchOptions.next = next;                 // caller-specified TTL + tags
  } else if (!previewToken) {
    fetchOptions.next = { revalidate: 60 };   // published default: 60s ISR
  } else {
    fetchOptions.cache = "no-store";          // draft/preview: always fresh
  }
  // ...
}

Callers override per data type

// Callers override the default per their staleness tolerance:

// Navigation — 5 min TTL + "navigation" tag so webhooks can bust it instantly
graphqlFetch(GET_NAV_QUERY, {}, { next: { revalidate: 300, tags: ["navigation"] } });

// Banner — 60s TTL + "banner" tag
graphqlFetch(GET_BANNER_QUERY, {}, { next: { revalidate: 60, tags: ["banner"] } });

// Search — always fresh (user-typed queries must never be stale)
graphqlFetch(SEARCH_QUERY, { query: q }, { cache: "no-store" });

// Preview — always fresh (draft content must bypass ISR entirely)
graphqlFetch(QUERY, vars, { previewToken: token }); // → cache: "no-store"

Graph's Response Cache — A Second Layer #

Two independent cache layers sit between an editor publishing content and a user seeing it. They are bypassed with different mechanisms — and cache: "no-store" in Next.js only skips Layer 1. Graph can still return a stale response from its own CDN cache unless you also add ?cache=false to the endpoint URL.

1

Next.js Fetch Cache

Lives in the Node.js / Vercel infrastructure layer. Controlled entirely by the fetch options you pass in graphqlFetch().

Cache with TTLnext: { revalidate: 60 }
Cache with tagnext: { tags: ['navigation'] }
Bypasscache: "no-store"
2

Graph CDN Cache

Lives at cg.optimizely.com on Optimizely's infrastructure. Applies to every request that doesn't opt out, regardless of what Next.js does with the response.

Cache(default — no action needed)
Bypass (raw)append ?cache=false to the URL
Bypass (SDK){ cache: false } in getContentByPath

Bypassing Graph's cache — raw URL vs SDK

// Layer 2: Graph's own CDN cache at cg.optimizely.com
// Bypassed by appending ?cache=false to the endpoint URL.
// Setting cache: "no-store" in Next.js only skips Layer 1 — Graph can still
// return a cached response unless you also pass ?cache=false.

const GATEWAY = process.env.OPTIMIZELY_GRAPH_GATEWAY;
// "https://cg.optimizely.com/content/v2"

// Standard — Graph may return a CDN-cached response:
fetch(`${GATEWAY}`, { method: "POST", ... });

// Bypass Graph cache — always fresh from Graph's data store:
fetch(`${GATEWAY}?cache=false`, { method: "POST", ... });

// SDK methods (getContentByPath, getContent) support { cache: false }
// which adds ?cache=false to the URL automatically:
const client = getClient();
await client.getContentByPath(url, { cache: false });
await client.getContent({ key, version }, { cache: false });

// The catch-all CMS page route (src/app/[[...slug]]/page.tsx) uses both:
export const dynamic = "force-dynamic";  // Layer 1: skip Next.js output cache
// ...
await client.getContentByPath(url, { cache: false });  // Layer 2: skip Graph CDN cache
// Result: every request gets the absolute latest content from Graph's data store.

When you need ?cache=false

  • Force-dynamic pages force-dynamic ensures Next.js re-renders the page on every request, but the fetch to Graph still executes on each render. Graph has its own query result cache and may return stale data if it hasn't been invalidated yet. Without ?cache=false, a user visiting right after a publish could see pre-publish content even though the page itself is freshly rendered.
  • Seed scripts and cache-warming — after indexing new content, subsequent queries need to verify the fresh data, not a Graph-cached version of the old data.
  • Preview / draft content — ensures the very latest draft is returned from Graph's data store rather than a cached published version.

When you don't need it

  • ISR pages with a revalidation window — if a page revalidates every 60s, Next.js ISR is already the controlling cache. Graph's short-lived CDN cache on top doesn't add meaningful staleness beyond what ISR already accepts.
  • Navigation, banners, and other tagged caches — these use 60–300s TTLs in Next.js ISR. Graph's cache sits inside that window and is evicted when the tag is revalidated.

How Publish → Cache Invalidation Works #

When an editor publishes, the ISR cache is invalidated automatically — no redeploy, no manual flush. Here's what actually happens, step by step.

1

Editor hits Publish in the CMS

Content is saved and the CMS begins syncing it to Optimizely Graph.

2

Graph indexes the updated content

Optimizely Graph processes the change and makes the new content queryable via its GraphQL API.

3

Graph sends a POST webhook to /api/webhooks

Just a signal — a small JSON payload saying "content changed" (type: bulk.completed or doc.updated). No content is sent in the webhook itself.

4

Next.js marks cached items as stale

The webhook handler calls revalidateTag("navigation"), revalidateTag("banner"), and revalidatePath("/", "layout"). Nothing is deleted or re-rendered yet — just flagged.

5

Next visitor arrives — gets the old cached version instantly

ISR always serves the existing cached version first, no matter what. The visitor doesn't wait for a re-render. This is what makes ISR fast.

6

Next.js re-renders in the background

After serving the stale version, Next.js fetches fresh data from Graph and rebuilds the affected pages and layout components behind the scenes.

7

Every request after that gets the updated version

The freshly rendered output is cached. Done — no redeploy needed.

Important — what ISR actually caches

The page content itself (the CMS page body) is force-dynamic — it is never cached. Every request always gets a fresh server render. The webhook only matters for layout components: navigation, banners, and referrals. Those are the only things ISR actually caches here.

Stale-while-revalidate in plain English

ISR never makes a visitor wait. When a cache is stale, the first person after a publish sees the old nav/banner for one request. Everyone after sees the updated version. For most content this is imperceptible — nav changes are low frequency.

Webhook Endpoints #

Three webhook routes handle different event sources. All return immediately — cache invalidation is synchronous but page regeneration is lazy (happens on the next request, not inline with the webhook).

POST /api/revalidate

path-specific or full-site bust

The most flexible endpoint. Send a specific path to regenerate one page, or omit it to bust the entire layout cache. Register this in CMS Settings → Events → Content Published. Requires the x-revalidate-secret header.

// POST /api/revalidate
// Header: x-revalidate-secret: <OPTIMIZELY_REVALIDATE_SECRET>
// Body:   { "path": "/about/" }   — or omit path for full-site bust

import { revalidatePath } from "next/cache";

export async function POST(request: NextRequest) {
  const secret = request.headers.get("x-revalidate-secret");
  if (secret !== process.env.OPTIMIZELY_REVALIDATE_SECRET) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
  const { path } = await request.json();
  path ? revalidatePath(path) : revalidatePath("/", "layout");
  return NextResponse.json({ revalidated: true, timestamp: Date.now() });
}

POST /api/webhooks

Optimizely Graph events

Registered directly with Optimizely Graph (via npm run webhook:register). Graph calls this endpoint for three event types: bulk.completed (sync finished), doc.updated (single item changed), doc.expired (item hit its StopPublish date). No secret required — Graph authenticates with HMAC in production.

// POST /api/webhooks  (registered via: npm run webhook:register)
// Triggered by Optimizely Graph on every content change — no secret required
// (Graph signs requests with HMAC; validate in production)

// Payload shapes:
// { "type": "bulk.completed",  ... }  — Graph finished a content sync
// { "type": "doc.updated",     ... }  — a single item was updated
// { "type": "doc.expired",     ... }  — item reached its StopPublish date

export async function POST(request: NextRequest) {
  const body = await request.json();
  revalidatePath("/", "layout");
  revalidateTag("navigation");          // navigation is the most time-sensitive
  return NextResponse.json({ received: true });
}

POST /api/publish

CMS publish events — full-site bust

A simpler variant of /api/revalidate that always busts the entire layout cache. Use this when you want a single "fire and forget" publish hook with no payload parsing. Register in CMS Settings → Events alongside /api/revalidate.

// POST /api/publish
// Header: x-revalidate-secret: <OPTIMIZELY_REVALIDATE_SECRET>
// Triggered by CMS Settings > Events > "Content Published"

export async function POST(request: NextRequest) {
  // auth check …
  revalidatePath("/", "layout");        // bust every ISR page
  return NextResponse.json({ received: true, timestamp: Date.now() });
}

Setup Guide #

  1. 1

    Set OPTIMIZELY_REVALIDATE_SECRET in your environment — a random string shared between the CMS and your app.

  2. 2

    In CMS admin: Settings → Events → Add event. Point to /api/revalidate (or /api/publish). Add x-revalidate-secret header with your secret.

  3. 3

    Register the Graph webhook: npm run webhook:register. This calls the Graph API to register /api/webhooks for bulk.completed, doc.updated, and doc.expired events.

  4. 4

    Publish any content in the CMS. Within seconds, the relevant ISR pages are marked stale and will regenerate on the next request.

  5. 5

    To verify: note the 'Last rendered' timestamp on this page, trigger a revalidation from the CMS, then reload — the timestamp should update on the next request.

Source files1 file
src/lib/optimizely/client.ts
const GRAPH_ENDPOINT =
  process.env.OPTIMIZELY_GRAPH_GATEWAY ?? "https://cg.optimizely.com/content/v2";

const SINGLE_KEY = process.env.OPTIMIZELY_GRAPH_SINGLE_KEY ?? "";

export interface GraphQLRequestOptions {
  /** Bearer token from CMS iframe for draft/preview content */
  previewToken?: string;
  /** Next.js fetch revalidation config */
  next?: { revalidate?: number; tags?: string[] };
  /** Override fetch cache behavior */
  cache?: RequestCache;
}

export interface GraphQLResponse<T> {
  data: T | null;
  errors?: Array<{ message: string; locations?: unknown; path?: unknown }>;
}

export async function graphqlFetch<T = unknown>(
  query: string,
  variables?: Record<string, unknown>,
  options: GraphQLRequestOptions = {}
): Promise<GraphQLResponse<T>> {
  const { previewToken, next, cache } = options;

  const headers: Record<string, string> = {
    "Content-Type": "application/json",
  };

  if (previewToken) {
    headers["Authorization"] = `Bearer ${previewToken}`;
  } else {
    headers["Authorization"] = `epi-single ${SINGLE_KEY}`;
  }

  const fetchOptions: RequestInit & { next?: { revalidate?: number; tags?: string[] } } = {
    method: "POST",
    headers,
    body: JSON.stringify({ query, variables }),
  };

  if (cache) {
    fetchOptions.cache = cache;
  } else if (next) {
    fetchOptions.next = next;
  } else if (!previewToken) {
    fetchOptions.next = { revalidate: 60 };
  } else {
    fetchOptions.cache = "no-store";
  }

  const response = await fetch(GRAPH_ENDPOINT, fetchOptions);

  if (!response.ok) {
    const body = await response.text().catch(() => "");
    throw new Error(
      `GraphQL request failed: ${response.status} ${response.statusText}${body ? ` — ${body}` : ""}`
    );
  }

  const result: GraphQLResponse<T> = await response.json();

  if (result.errors?.length) {
    console.error("[GraphQL Errors]", JSON.stringify(result.errors, null, 2));
  }

  return result;
}