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.
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.
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.
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 revalidatePath("/", "layout") and revalidateTag() for navigation, banner, and quotes. Nothing is deleted or re-rendered yet - just flagged as stale.
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.
CMS page content is ISR-cached per variation
/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
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.
| Data | Location | TTL | Cache tag | Revalidated by |
|---|---|---|---|---|
| CMS page content | getClient().getContentByPath() | 3600s (1 hr) | - | revalidatePath('/', 'layout') in /api/webhooks (page-output ISR only) |
| Navigation tree | getNavigation() | 3600s (1 hr) | navigation | revalidateTag('navigation') in /api/webhooks |
| Site banner | getSiteBanner() | 3600s (1 hr) | banner | revalidateTag('banner') in /api/webhooks |
| External quotes | getQuotes() | 3600s (1 hr) | quotes | revalidateTag('quotes') in /api/webhooks |
| Page metadata | generateMetadata() | 3600s (1 hr) | - | All three webhooks via revalidatePath('/', 'layout') |
| Static page paths | generateStaticParams() | 3600s (1 hr) | - | Next.js build / deploy |
| FX datafile | middleware.ts + experimentation.ts | 60s | - | Automatic (fetch cache, next: { revalidate: 60 }) |
| 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 |
| Link prefetch (RSC payload) | browser router cache | 5 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
| Method | When to use | ISR support |
|---|---|---|
| getClient().getContentByPath(url) | Default for fetching a CMS page by URL - used in the catch-all page route | Page-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 component | Page-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 calls | Nocache 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?
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.
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.
next: { revalidate: 3600 }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 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-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 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
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.Client-side Prefetching #
In production, Next.js <Link> automatically prefetches the RSC payload for every link that enters the viewport. The prefetch is cached in the browser's router cache for 5 minutes (static/ISR routes) or 0 seconds (dynamic routes), making subsequent navigation to that page instant. This is a browser-side cache - independent of Vercel CDN or Next.js ISR.
Hover-triggered dropdowns - prefetch is intentional
The nav dropdown children are conditionally rendered - they only enter the DOM when the user hovers the parent. Next.js sees them enter the viewport at hover time and immediately fires prefetch requests. This is the ideal moment: the user is about to click, so having the RSC payload ready makes navigation feel instant.
Because the pages are ISR-cached at Vercel's CDN, these prefetch requests are cheap CDN hits - not origin calls. Leaving default prefetch behaviour on nav dropdown links is correct.
Always-visible bulk links - use prefetch=false
The footer in this project renders 20 demo links on every page, always in the DOM. With default prefetch behaviour, every page load fires 20 RSC prefetch requests immediately - even if the user never scrolls to the footer.
Adding prefetch={false} to those links eliminates the unnecessary requests. Navigation to footer links is still fast because the ISR CDN cache is warm - the first click just fetches the RSC payload at that moment rather than eagerly.
When to use prefetch={false}
// Next.js <Link> prefetch behaviour in App Router (production only):
//
// 1. When a <Link> enters the viewport, Next.js fetches the RSC payload
// for that route and caches it in the browser's router cache.
// 2. Clicking the link navigates instantly - no round-trip needed.
//
// Router cache staleness (Next.js 15+):
// Static / ISR routes → 5 minutes
// Dynamic routes → 0 seconds (always fetches on navigation)
//
// Implication: always-rendered links prefetch eagerly, even if the user
// never clicks them. 20 footer links = 20 prefetch requests on every page load.
// FIX for bulk always-visible links - disable prefetch
<Link href="/demo/caching" prefetch={false}>Caching</Link>
// Hover-triggered dropdowns are fine WITHOUT prefetch={false}:
// Child <Link> elements only enter the DOM when the dropdown opens (hover).
// At that moment Next.js prefetches them - which is exactly when the user
// is most likely to click. Intentional and beneficial.
{isDropdownOpen && (
<Link href="/en/investments">Investments</Link> // prefetch fires on hover
)}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).
HMAC validation is required before production use
/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 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 ?? "";
// 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;
}