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.
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.
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.
| Data | Location | TTL | Cache tag | Revalidated by |
|---|---|---|---|---|
| CMS page content | getClient().getContentByPath() | 60s | — | /api/revalidate or /api/webhooks |
| Navigation tree | getNavigation() | 300s (5 min) | navigation | revalidateTag('navigation') in /api/webhooks |
| Site banner | getSiteBanner() | 60s | banner | revalidateTag('banner') — manual |
| External referrals | getReferrals() | 60s | referrals | revalidateTag('referrals') — manual |
| Page metadata | generateMetadata() | 300s (5 min) | — | /api/revalidate |
| Static page paths | generateStaticParams() | 3600s (1 hr) | — | Next.js build / deploy |
| FX datafile | buildClient() in experimentation.ts | 60s | — | Automatic (fetch cache) |
| Search results | GET /api/search | no-store | — | Always fresh — bypasses ISR |
| Draft/preview | client.getPreviewContent() | no-store | — | Always fresh — bypasses ISR |
| Graph CDN cache | cg.optimizely.com/content/v2 | Graph-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.
Next.js Fetch Cache
Lives in the Node.js / Vercel infrastructure layer. Controlled entirely by the fetch options you pass in graphqlFetch().
next: { revalidate: 60 }next: { tags: ['navigation'] }cache: "no-store"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.
(default — no action needed)append ?cache=false to the URL{ cache: false } in getContentByPathBypassing 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-dynamicensures 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.
Editor hits Publish in the CMS
Content is saved and the CMS begins syncing it to Optimizely Graph.
Graph indexes the updated content
Optimizely Graph processes the change and makes the new content queryable via its GraphQL API.
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.
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.
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.
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.
Every request after that gets the updated version
The freshly rendered output is cached. Done — no redeploy needed.
Important — what ISR actually caches
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
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 bustThe 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 eventsRegistered 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 bustA 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
Set OPTIMIZELY_REVALIDATE_SECRET in your environment — a random string shared between the CMS and your app.
- 2
In CMS admin: Settings → Events → Add event. Point to /api/revalidate (or /api/publish). Add x-revalidate-secret header with your secret.
- 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
Publish any content in the CMS. Within seconds, the relevant ISR pages are marked stale and will regenerate on the next request.
- 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
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;
}