Developer Demo

ISR Caching & Webhooks

ISR (Incremental Static Regeneration) is Next.js's caching model: pages are pre-built as static HTML and served instantly from a CDN edge, then automatically refreshed in the background when content changes - no redeploy needed. This demo shows how Optimizely Graph integrates with ISR so editors can publish and see changes live in seconds while visitors always get fast, cached responses.

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-07-25T22:16:09.663Z

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. For a full system view including the edge middleware and Graph layers, see the Architecture - Publish Flow.

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 revalidatePath("/", "layout") and revalidateTag() for navigation, banner, and quotes. Nothing is deleted or re-rendered yet - just flagged as stale.

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.

CMS page content is ISR-cached per variation

Edge middleware rewrites each visitor's URL with their active FX variation key (e.g. /savings/__v_homepage--variation_1, one segment per active flag in the format __v_flagKey--variationKey). Each rewritten URL is its own 1-hour ISR cache entry - base users and every variation are cached independently. The publish webhook marks all of them stale at once.

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.

Caching Strategy #

Every data source in the project has an explicit caching policy. TTL (Time To Live) is how long a cached version is kept before Next.js considers it stale and eligible for a background refresh. Not all sources use the same invalidation mechanism: CMS page content fetched via getContentByPath() relies on page-output ISR and revalidatePath('/', 'layout') in the webhook - the whole page re-renders on the next request. Shared data sources with different update cadences (navigation, banner, external quotes) use per-fetch cache tags so a single revalidateTag('navigation') call busts only that data across all pages without re-rendering anything else. Search is always fresh because user-typed queries must never be stale.

DataLocationTTLCache tagRevalidated by
CMS page contentgetClient().getContentByPath()3600s (1 hr)-revalidatePath('/', 'layout') in /api/webhooks (page-output ISR only)
Navigation treegetNavigation()3600s (1 hr)navigationrevalidateTag('navigation') in /api/webhooks
Site bannergetSiteBanner()3600s (1 hr)bannerrevalidateTag('banner') in /api/webhooks
External quotesgetQuotes()3600s (1 hr)quotesrevalidateTag('quotes') in /api/webhooks
Page metadatagenerateMetadata()3600s (1 hr)-All three webhooks via revalidatePath('/', 'layout')
Static page pathsgenerateStaticParams()3600s (1 hr)-Next.js build / deploy
FX datafilemiddleware.ts + experimentation.ts60s-Automatic (fetch cache, next: { revalidate: 60 })
Search resultsGET /api/searchno-store-Always fresh - bypasses ISR
Draft/previewclient.getPreviewContent()no-store-Always fresh - bypasses ISR
Graph CDN cachecg.optimizely.com/content/v2Graph-managed-?cache=false on the request URL - see section below
Link prefetch (RSC payload)browser router cache5 min (static) / 0s (dynamic)-Page navigation or TTL expiry

Choosing the Right Method #

The SDK provides getContentByPath(), getContent(), and request() for querying Optimizely Graph. These cover most cases. This project also includes a thin custom graphqlFetch() wrapper in src/lib/optimizely/client.ts that wraps the native fetch() directly - the only way in Next.js to attach next: { revalidate, tags } options for per-fetch ISR tagging. Use it only when data sources have different update cadences and you want to bust them independently.

Which method to use

MethodWhen to useISR support
getClient().getContentByPath(url)Default for fetching a CMS page by URL - used in the catch-all page routePage-levelPage-output ISR via export const revalidate - sufficient for most pages; no per-fetch tagging needed
getClient().getContent({ key })Resolve a content reference by CMS key inside a componentPage-levelSame as getContentByPath() - benefits from the page's revalidate window; next/tags options are silently discarded by the SDK
graphqlFetch(query)Only when you need per-fetch tags or a different TTL to data sources with a different update cadence (nav, banner, external data)Fetch-levelFull next: { revalidate, tags } support - wraps fetch() directly so Next.js registers each call in its data cache
getClient().request(query)Escape hatch for queries where ISR is not needed - preview fetches, server actions, one-off no-store callsNocache param appends ?cache=true/false to the Graph URL - Next.js fetch cache never sees it

SDK methods and the as any cast - it does not work

getClient().getContent() and getContentByPath() only read options.cache (a boolean controlling the Graph CDN URL parameter). Any next property you pass - even cast as any - is silently discarded before reaching the underlying fetch() call, because both methods route through this.request() which does not forward Next.js fetch options. They participate in page-output ISR only - not fetch-level tag revalidation. SDK docs ↗

When does the custom wrapper add value?

Only when you need per-fetch cache tags with different TTLs per data source - for example, navigation at 5 minutes and banners at 60 seconds, each bust-able independently via revalidateTag() without re-rendering every page. For CMS page content fetched via getContentByPath(), page-output ISR combined with revalidatePath('/', 'layout') in the webhook is sufficient and simpler. The custom wrapper exists for the cases where you want surgical invalidation by data source rather than a full-site re-render on every publish.

The custom graphqlFetch wrapper (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: 3600 }; // published default: 1-hour ISR
  } else {
    fetchOptions.cache = "no-store";          // draft/preview: always fresh
  }
  // ...
}
// Callers override the default per their staleness tolerance:

// Navigation - 1-hour TTL + "navigation" tag so webhooks can bust it instantly
graphqlFetch(GET_NAV_QUERY, {}, { next: { revalidate: 3600, tags: ["navigation"] } });

// Banner - 1-hour TTL + "banner" tag
graphqlFetch(GET_BANNER_QUERY, {}, { next: { revalidate: 3600, 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"

Why getClient().request() cannot participate in Next.js ISR

// getClient().request() - the SDK's built-in raw query method
// Its "cache" parameter appends ?cache=true/false to the Graph endpoint URL.
// This controls Graph's own server-side CDN cache - NOT the Next.js fetch cache.

async request(query, variables, previewToken, cache = true, slot) {
  const url = new URL(this.graphUrl);
  url.searchParams.append("cache", cache.toString()); // → ?cache=true appended to URL
  const response = await fetch(url, {
    method: "POST",
    headers,
    body: JSON.stringify({ query, variables }),
    // ↑ No "next" property here. Next.js never sees this as an ISR fetch.
  });
}

// Consequence: you cannot tag this fetch or give it a revalidate window.
// revalidateTag("navigation") has no effect on a fetch made via request().

// Use graphqlFetch instead when you need ISR:
graphqlFetch(QUERY, vars, { next: { revalidate: 3600, tags: ["navigation"] } });
// ↑ Next.js registers this fetch in its data cache and respects the tags.

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 by the next options passed to the underlying fetch() call - either directly or via the custom graphqlFetch() wrapper.

Cache with TTLnext: { revalidate: 3600 }
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 ISR:
export const revalidate = 3600;  // Layer 1: ISR - cache page output for 1 hour
// Middleware rewrites each visitor's URL with active FX variation segments:
//   /savings                                   → base users (no active variation)
//   /savings/__v_homepage--variation_1         → one active flag
//   /savings/__v_homepage--var1/__v_cta--on    → two active flags
// Format: __v_{flagKey}--{variationKey} per segment, sorted for a stable cache key.
// Each rewritten URL is a separate ISR cache entry at the CDN.
// Graph data fetches use next: { revalidate: 3600, tags: ["page"] }.

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 hour, 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 a 1-hour TTL in Next.js ISR. Graph's cache sits inside that window and is evicted when the tag is revalidated.

What kills ISR (and how to fix it) #

Next.js detects any call to cookies() or headers() from next/headers during a render and forces cache-control: no-store on the entire response - even if export const revalidate = 60 is set on the page. The call does not have to be in the page component itself; it kills ISR if it appears anywhere in the chain of server components that render the page - including shared layout components like the site header or footer.

Dynamic APIs in server components

cookies(), headers(), and searchParams are "dynamic APIs" in Next.js. Accessing any of them during a server render tells Next.js the response depends on the request - so it cannot be cached. The page is downgraded to SSR for that request.

Layout components are shared

The penalty applies to the entire response, not just the component that called cookies(). A single cookies() call in a shared header or footer forces no-store on every page that uses that layout - even pages that don't need any per-user data.

The fix: push dynamic reads client-side

Server components should fetch only static, cacheable data. Pass that data as props to a "use client" component. The client component reads cookies in useEffect after hydration - completely outside the server render tree and therefore invisible to Next.js's cache rules.

Pattern - server fetches static data, client handles cookies

// Pattern 1 - cookies() or headers() anywhere in the server render tree
import { cookies } from "next/headers";

export default async function AnyServerComponent() {
  const session = cookies().get("session"); // forces no-store on entire response
  // ...                                    // even if the page has revalidate = 60
}

// Pattern 2 - explicit opt-out on the page or a parent layout
export const dynamic = "force-dynamic";  // always SSR, never ISR-cached
export const revalidate = 0;             // same effect

// Pattern 3 - reading searchParams in a page component
export default async function Page({ searchParams }) {
  const q = searchParams.q; // searchParams is a dynamic API - opts page out of ISR
}

// Fix: server component fetches only static data; client component reads cookies
// Server - no cookies(), no headers(), fully cacheable
export default async function Banner() {
  const data = await fetchStaticData(); // e.g. a CMS query with next: { revalidate: 3600 }
  return <BannerClient initialData={data} />;
}

// "use client" - cookie access stays out of the server render tree
"use client";
export function BannerClient({ initialData }) {
  const [content, setContent] = useState(initialData); // renders from props on first paint

  useEffect(() => {
    const userId = document.cookie.match(/userId=([^;]*)/)?.[1];
    // personalise or A/B test here - runs after hydration, never blocks ISR
    setContent(personalise(initialData, userId));
  }, [initialData]);

  return content ? <div>{content.message}</div> : null;
}

Initialise from props to avoid layout shift

When a server component passes static data as props to a client component, initialise the client component's state directly from those props. The server renders the static version as HTML; the client hydrates with the same value; then useEffect runs and overwrites with the personalised version if needed. No layout shift, no hydration mismatch - the page looks correct on first paint and silently updates after hydration.

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).

HMAC validation is required before production use

This demo accepts any POST to /api/webhooks without authentication. In production, Graph signs each request with an HMAC-SHA256 signature in the X-Graph-Signature header. Validate it before calling revalidatePath or revalidateTag - an unauthenticated endpoint lets anyone trigger a full-site cache bust on demand.
// 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

// Next.js 16 adds a second "profile" arg to revalidateTag's type signature
// (for Server Action cache profiles). Route handlers have no valid profile,
// so cast to the single-arg overload to keep TypeScript happy.
import { revalidateTag as _revalidateTag } from "next/cache";
const revalidateTag = _revalidateTag as (tag: string) => void;

export async function POST(request: NextRequest) {
  const body = await request.json();
  revalidatePath("/", "layout"); // bust ISR page output cache
  revalidateTag("page");         // bust Graph fetch cache for CMS pages
  revalidateTag("navigation");   // navigation tree (1-hour TTL)
  revalidateTag("banner");       // site banner (1-hour TTL)
  revalidateTag("quotes");       // external quotes (1-hour TTL)
  return NextResponse.json({ received: true, timestamp: Date.now() });
}

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 ?? "";

// Default time-based ISR window (seconds) for published content. Freshness is
// driven by the publish webhook (revalidatePath/revalidateTag); this 1-hour TTL
// is the fallback ceiling. Keep the `export const revalidate` in the catch-all
// page route in sync with this value.
export const CACHE_TTL = 3600;

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: CACHE_TTL };
  } 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;
}