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-09-19T13:28:15.750Z

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)
Homepage (/) onlysame, but noStore()none-Not cached at all - the ODP branch reads cookies(), so / server-renders per request while every other CMS route is ISR
Navigation treegetNavigation()3600s (1 hr)navigationrevalidateTag('navigation') in /api/webhooks - "use cache" entry, preview branch uncached
Site bannergetSiteBanner()3600s (1 hr)bannerrevalidateTag('banner') in /api/webhooks
Site footergetFooter()3600s (1 hr)footerrevalidateTag('footer') in /api/webhooks - "use cache" entry
Site settingsgetSiteSettings()3600s (1 hr)settingsrevalidateTag('settings') in /api/webhooks - "use cache" entry
External quotesgetQuotes()3600s (1 hr)quotesrevalidateTag('quotes') in /api/webhooks
Quote blocksgetQuoteBlocks()3600s (1 hr)quote-blocksrevalidateTag('quote-blocks') in /api/webhooks
Branch locationsgetLocations()3600s (1 hr)locationsrevalidateTag('locations') in /api/webhooks - the nearby search is NOT cached, its lat/lon args come from user input
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. None of them forward next: { revalidate, tags } to the underlying fetch, so none of them can be tagged at the fetch level. That is not a problem to work around with a fetch wrapper: put the query inside a "use cache" function instead. It caches the returned value rather than the fetch, so cacheTag() and cacheLife() work over any client at all - including the SDK's.

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. Its next/tags options are discarded, so wrap the call in a "use cache" function when you want a tag
"use cache" + graphClient().request(query)Any custom query that needs its own tag or TTL - nav, footer, site settings, external dataFetch-levelcacheTag()/cacheLife() on the function, so the client's cache-awareness is irrelevant. revalidateTag expires the entry
graphClient().request(query)Uncached by design - preview/draft fetches and user-typed search, which must never be cachedNocache param appends ?cache=true/false to the Graph URL, which is Graph's own CDN. The Next data cache never sees it, though page-output ISR still applies

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. On their own they participate in page-output ISR only - not fetch-level tag revalidation. To give either one a tag, call it inside a "use cache" function and put cacheTag() there instead - the cache boundary is the function, so the SDK's fetch options never come into it.

Do not reach for a fetch wrapper

Wrapping the native fetch() to attach next: { revalidate, tags } is the obvious move once you notice the SDK will not tag its own fetch, and it is the wrong one: it re-implements the auth-mode switching getClient() already does, and it gives up the typed query building along with it. A "use cache" function caches the returned value instead, so you keep the SDK client and still get per-source tags.

Caching a query - "use cache" over the SDK client

GetFooter.ts - cache the returned value, not the fetch
// next.config.ts - required, or cacheTag() throws E886.
// NOT cacheComponents: true, which would also force ppr: true and demand
// Suspense boundaries around every dynamic read.
experimental: { useCache: true }
// Caveat: this forbids "export const runtime" anywhere in app/.

// src/lib/graphql/queries/GetFooter.ts
import { cacheLife, cacheTag } from "next/cache";
import { graphClient } from "@/lib/optimizely/graphClient";

async function fetchFooter(locale: string): Promise<GetFooterResult> {
  "use cache";
  cacheTag("footer");
  cacheLife({ stale: 300, revalidate: CACHE_TTL, expire: CACHE_TTL * 24 });

  try {
    return await graphClient().request(GET_FOOTER_QUERY, { locale: [locale] });
  } catch (error) {
    // The catch goes INSIDE, which is the opposite of the instinct. A rejected
    // promise inside "use cache" fails static generation outright ("Error
    // occurred prerendering page") and NO try/catch at the call site can
    // rescue it - the boundary swallows the rejection before it gets there.
    console.error("[fetchFooter] Graph query failed:", error);
    return {};   // caller's existing fallback path takes over
  }
}

export async function getFooter(options: { locale?: string } = {}) {
  const { locale = "en" } = options;
  try {
    const data = await fetchFooter(locale);   // args are the cache key
    // ... unchanged mapping
  } catch (error) {
    // Still worth keeping, but it now only catches MAPPING errors - Graph
    // failures were already handled above.
  }
}

// Four rules this imposes:
// 1. Catch inside the boundary, not outside (above). The price is real: a Graph
//    outage during a render is written into the cache entry and served for the
//    rest of the revalidate window. Accept it, or shorten cacheLife on the
//    queries where an hour of empty is worse than an hour of stale.
// 2. No cookies()/headers()/draftMode() inside - it throws. Read them outside
//    and pass the value in as an argument.
// 3. Args and return value must be serializable. They form the cache key:
//    fetchNavigationCached(undefined, "en") keys as ["$undefined","en"].
// 4. Preview fetches stay outside the boundary entirely. See GetNavigation.ts:
//    the previewToken branch calls request() directly, uncached.

// Use graphClient(), not getClient(): getClient() throws when config() has not
// run, and config() lives in componentRegistry.ts, which only PAGE routes
// import. Site chrome renders from layout.tsx, so a direct getClient() call
// throws on every route that skips the registry (all of /demo/*) and silently
// degrades to static fallback data.
Per-source tags, and the paths that must stay uncached
// Each data source gets its own tag, so a webhook can bust one
// without re-rendering everything else.

// Navigation - 1-hour TTL + "navigation" tag
async function fetchNavigationCached(key, locale) {
  "use cache";
  cacheTag("navigation");
  cacheLife({ stale: 300, revalidate: 3600, expire: 86400 });
  return graphClient().request(GET_NAV_QUERY, { locale: [locale] });
}

// Search - never cached (user-typed queries must always be fresh),
// so there is no cache boundary at all:
graphClient().request(SEARCH_QUERY, { query: q }, undefined, false);

// Preview - a draft must never be cached, and a preview token is dynamic
// data that cannot cross a cache boundary. Call request() directly:
graphClient().request(QUERY, vars, previewToken, false);
//                                  ↑ token     ↑ cache=false also bypasses
//                                               Graph's own CDN

What getClient().request() can and cannot cache

// 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, so Next applies autoNoCache: this call is never
    //   registered in the FETCH DATA CACHE and revalidateTag cannot reach it.
  });
}

// That is the precise limit: no per-fetch tag, no per-fetch revalidate window.
// It does NOT mean "no ISR" - the result is still captured by page-output ISR
// (export const revalidate), and during build-time prerendering the fetch IS
// cached and shared between export workers.

// The fix is not a fetch wrapper. "use cache" caches the RETURNED VALUE, so how
// the data was fetched stops mattering:
async function fetchFooter(locale) {
  "use cache";
  cacheTag("footer");
  cacheLife({ stale: 300, revalidate: 3600, expire: 86400 });
  return graphClient().request(GET_FOOTER_QUERY, { locale: [locale] });
}
// ↑ revalidateTag("footer") DOES expire this entry - verified with
//   NEXT_PRIVATE_DEBUG_CACHE=1: the entry re-sets on the next render.

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 Data Cache

Lives in the Node.js / Vercel infrastructure layer. For a Graph query it is controlled by cacheTag() / cacheLife() inside the "use cache" function that wraps it - the SDK client never passes next options to its own fetch().

Cache with TTLcacheLife({ revalidate: 3600 })
Cache with tagcacheTag('navigation')
Bypassno "use cache" boundary
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 cookieStore = await cookies();
  const session = cookieStore.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).

Always verify the sender

An open receiver is a free cache-flush endpoint - anyone who can POST here can force every request back to Graph. Graph webhook registration controls only the URL, not custom headers, so this app appends a shared secret as a query parameter (?secret=OPTIMIZELY_REVALIDATE_SECRET) and the route compares it before invalidating - denying by default. See Registering & testing webhooks below.
// POST /api/webhooks  (registered via: npm run webhook:register)
// Triggered by Optimizely Graph on every content change.

// 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) {
  // Graph webhook registration controls only the URL, not custom headers, so
  // the shared secret rides along as a query param. Deny by default.
  const secret =
    request.nextUrl.searchParams.get("secret") ??
    request.headers.get("x-revalidate-secret");
  if (secret !== process.env.OPTIMIZELY_REVALIDATE_SECRET) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  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("footer");       // site footer (1-hour TTL)
  revalidateTag("settings");     // site settings (1-hour TTL)
  revalidateTag("quotes");       // external quotes (1-hour TTL)
  revalidateTag("quote-blocks"); // quote blocks (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() });
}

Registering & testing webhooks #

The /api/webhooks receiver above only fires once Graph is told where to send events. Registration happens against Graph's management endpoint with Basic auth, and because Graph can only reach public URLs, local development needs a tunnel.

Registration

Webhooks are registered with Basic auth using the OPTIMIZELY_APP_KEY / OPTIMIZELY_APP_SECRET pair (created in CMS Settings → API Keys). Registration controls only the URL and method - no custom headers - so the shared secret is appended as a query parameter. The npm run webhook:register script wraps this in a prompt for your public base URL.

POST https://cg.optimizely.com/api/webhooks
// scripts/register-webhook.mjs (run with: npm run webhook:register)
//
// Graph's webhook API authenticates with HTTP Basic auth using the
// OPTIMIZELY_APP_KEY / OPTIMIZELY_APP_SECRET pair (the same credentials
// used for the Content Source API - created in CMS Settings > API Keys).

const credentials = Buffer.from(`${APP_KEY}:${APP_SECRET}`).toString("base64");

await fetch("https://cg.optimizely.com/api/webhooks", {
  method: "POST",
  headers: {
    Authorization: `Basic ${credentials}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    disabled: false,
    request: {
      // Graph only lets you control the URL, not custom headers - so the
      // shared secret rides along as a query parameter.
      url: "https://your-app.com/api/webhooks?secret=<OPTIMIZELY_REVALIDATE_SECRET>",
      method: "post",
    },
    topic: ["*.*"],   // all events; narrow to e.g. ["doc.updated"] if preferred
  }),
});

// List registered webhooks at any time:
//   curl -u '<app-key>:<app-secret>' https://cg.optimizely.com/api/webhooks

Testing locally

Graph needs a public URL to deliver events, so local development requires a tunnel. You can also exercise the receiver directly with curl - useful for confirming the 401 path before registering anything.

Tunnel + manual testing
# Graph can't reach localhost - expose your dev server with a tunnel first:
npx ngrok http 3000          # or: cloudflared tunnel --url http://localhost:3000

# Then register the tunnel URL:
npm run webhook:register
# > Enter your public base URL: https://<random>.ngrok-free.app

# Test the receiver by hand (should be 401 without the secret):
curl -X POST http://localhost:3000/api/webhooks
curl -X POST "http://localhost:3000/api/webhooks?secret=$OPTIMIZELY_REVALIDATE_SECRET" \
  -H "Content-Type: application/json" -d '{"topic":"doc.updated"}'

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 files4 files
src/lib/graphql/queries/GetFooter.ts
import { cacheTag } from "next/cache";
import { CACHE_TAGS, cachePublishedContent, cachedQueryFailed } from "@/lib/optimizely/cacheProfile";
import { graphClient } from "@/lib/optimizely/graphClient";
import { toNavNode, type NavNode, type RawNavItem } from "@/lib/graphql/queries/GetNavigation";

export interface FooterData {
  tagline: string | null;
  /** Each column is a NavigationItem: label = column heading, children = links. */
  columns: NavNode[];
}

interface GetFooterResult {
  Footer?: {
    items?: Array<{
      tagline?: string | null;
      columns?: Array<RawNavItem | { __typename?: string }> | null;
    } | null> | null;
  } | null;
}

// Display name written by seed-footer.ts - how editors identify the Footer
// block in the Shared Blocks tab. Not used for querying (the query fetches
// by type).
export const FOOTER_BLOCK_NAME = "Site Footer";

const GET_FOOTER_QUERY = /* GraphQL */ `
  fragment FooterNavItemFields on _IContent {
    ... on NavigationItem {
      __typename
      _metadata { key }
      label
      href { url { default } }
      openInNewTab
      # No description field - FooterColumns renders label + href + target only.
      # Depth 2 rather than 3: the footer is columns -> links, one level deep.
      children @recursive(depth: 2)
    }
  }

  query GetFooter($locale: [Locales]) {
    # Newest first: deleted blocks can linger in the Graph index after a
    # re-seed; ordering by lastModified guarantees the live block wins.
    Footer(locale: $locale, orderBy: { _metadata: { lastModified: DESC } }, limit: 1) {
      items {
        tagline
        columns {
          ...FooterNavItemFields
        }
      }
    }
  }
`;

// The Graph call sits in a "use cache" function rather than passing
// next: { revalidate, tags } to fetch(), because the SDK's request() does not
// forward Next.js fetch options. "use cache" caches the returned value instead,
// so cacheTag/cacheLife work over any client. Args must stay serializable.
async function fetchFooter(locale: string): Promise<GetFooterResult> {
  "use cache";
  cacheTag(CACHE_TAGS.footer);
  cachePublishedContent();

  try {
    return await graphClient().request(GET_FOOTER_QUERY, { locale: [locale] });
  } catch (error) {
    return cachedQueryFailed("fetchFooter", error);
  }
}

/**
 * Fetch the Footer shared block and map its columns into typed NavNode trees
 * (reusing the Navigation mapper - footer columns are NavigationItems).
 *
 * Cached for 1 hour with a "footer" tag - the publish webhook calls
 * revalidateTag("footer") to bust on demand.
 *
 * Returns null when the block doesn't exist or can't be reached, so the
 * Footer component falls back to its hardcoded output.
 */
export async function getFooter(options: { locale?: string } = {}): Promise<FooterData | null> {
  const { locale = "en" } = options;
  try {
    const data = await fetchFooter(locale);
    const root = data?.Footer?.items?.[0];
    if (!root) return null;

    const columns = (root.columns ?? [])
      .filter((c): c is RawNavItem => (c as RawNavItem).__typename === "NavigationItem")
      .map(toNavNode);

    if (columns.length === 0 && !root.tagline) return null;
    return { tagline: root.tagline ?? null, columns };
  } catch (error) {
    console.error("[getFooter] Falling back to hardcoded footer:", error);
    return null;
  }
}
src/lib/graphql/queries/GetNavigation.ts
import { cacheTag } from "next/cache";
import { CACHE_TAGS, cachePublishedContent, cachedQueryFailed } from "@/lib/optimizely/cacheProfile";
import { graphClient } from "@/lib/optimizely/graphClient";

// Public tree type — used by NestedNavMenu and the demo page

export interface NavNode {
  key: string;
  label: string;
  href: string;
  description?: string;
  openInNewTab?: boolean;
  children: NavNode[];
}

// Raw GraphQL response types

export interface RawNavItem {
  __typename?: string;
  _metadata?: { key?: string | null } | null;
  label?: string | null;
  // href is a ContentReference in Graph — url.default holds the URL string
  href?: { url?: { default?: string | null } | null } | null;
  description?: string | null;
  openInNewTab?: boolean | null;
  // Recursively typed — children are the same shape (any depth)
  children?: Array<RawNavItem | { __typename?: string }> | null;
}

interface GetNavigationResult {
  Navigation?: {
    items?: Array<{
      name?: string | null;
      navItems?: Array<RawNavItem | { __typename?: string }> | null;
    } | null> | null;
  } | null;
}

// Query
//
// A named fragment captures the repeated scalar fields so the nesting levels
// stay readable. GraphQL does not allow recursive fragments, so each level is
// written out explicitly — this makes the depth limit clear and intentional.

/**
 * The @recursive directive tells Optimizely Graph to apply this fragment to
 * the items in the decorated content area field at each nesting level, up to
 * the given depth. No need to repeat inline fragments manually — the directive
 * handles arbitrary depth with a single fragment definition.
 *
 * depth: 5 → NavRoot → L1 → L2 → L3 → L4 → L5
 */
// Display name written by seed-nav.ts — how editors identify the Navigation
// block in the Shared Blocks tab. Not used for querying (Graph doesn't index
// the name property for filtering).
export const NAV_BLOCK_NAME = "Navigation Menu";

export const GET_NAVIGATION_QUERY = /* GraphQL */ `
  fragment NavItemFields on _IContent {
    ... on NavigationItem {
      __typename
      _metadata { key }
      label
      href { url { default } }
      description
      openInNewTab
      children @recursive(depth: 5)
    }
  }

  query GetNavigation($locale: [Locales]) {
    # Newest first: deleted blocks can linger in the Graph index for a while
    # after a re-seed; ordering by lastModified guarantees the live block wins.
    Navigation(locale: $locale, orderBy: { _metadata: { lastModified: DESC } }, limit: 1) {
      items {
        name
        navItems {
          ...NavItemFields
        }
      }
    }
  }
`;

const GET_NAVIGATION_BY_KEY_QUERY = /* GraphQL */ `
  fragment NavItemFieldsByKey on _IContent {
    ... on NavigationItem {
      __typename
      _metadata { key }
      label
      href { url { default } }
      description
      openInNewTab
      children @recursive(depth: 5)
    }
  }

  query GetNavigationByKey($key: String!, $locale: [Locales]) {
    Navigation(
      where: { _metadata: { key: { eq: $key } } }
      locale: $locale
      limit: 1
    ) {
      items {
        name
        navItems {
          ...NavItemFieldsByKey
        }
      }
    }
  }
`;

// Response mapper

export function toNavNode(raw: RawNavItem): NavNode {
  return {
    key: raw._metadata?.key ?? "",
    label: raw.label ?? "",
    href: raw.href?.url?.default ?? "#",
    description: raw.description ?? undefined,
    openInNewTab: raw.openInNewTab ?? false,
    children: (raw.children ?? [])
      .filter((c): c is RawNavItem => (c as RawNavItem).__typename === "NavigationItem")
      .map(toNavNode),
  };
}

// Fetch helper

// The cache boundary is this function, not the fetch: the SDK's request() does not
// forward next: { revalidate, tags }, but "use cache" caches the returned value, so
// cacheTag/cacheLife apply regardless of the client. The preview path deliberately
// stays outside it - a draft must never be cached or tagged, and reading a preview
// token is dynamic data that cannot cross a cache boundary.
async function fetchNavigationCached(key: string | undefined, locale: string): Promise<GetNavigationResult> {
  "use cache";
  cacheTag(CACHE_TAGS.navigation);
  cachePublishedContent();

  try {
    return await graphClient().request(
      key ? GET_NAVIGATION_BY_KEY_QUERY : GET_NAVIGATION_QUERY,
      key ? { key, locale: [locale] } : { locale: [locale] }
    );
  } catch (error) {
    return cachedQueryFailed("fetchNavigationCached", error);
  }
}

/**
 * Fetch the Navigation shared block and map its navItems into a typed NavNode
 * tree.
 *
 * By default fetches the newest Navigation block by type (the display name is
 * not used), so re-seeding with a new CMS key is transparent. Pass `key` to
 * query a specific Navigation block (e.g. for preview).
 *
 * Cached for 1 hour with a "navigation" tag — call revalidateTag("navigation")
 * from a publish webhook to bust on demand. Preview fetches bypass the cache.
 *
 * Falls back to DEMO_NAV_DATA when the block can't be reached.
 */
export async function getNavigation(options: {
  previewToken?: string;
  key?: string;
  /** Content locale to fetch (defaults to "en"). Localized nav versions are
   *  created by scripts/seed-localization.ts. */
  locale?: string;
} = {}): Promise<{ tree: NavNode[]; fromCms: boolean }> {
  const { previewToken, key, locale = "en" } = options;

  try {
    const data: GetNavigationResult = previewToken
      ? await graphClient().request(
          key ? GET_NAVIGATION_BY_KEY_QUERY : GET_NAVIGATION_QUERY,
          key ? { key, locale: [locale] } : { locale: [locale] },
          previewToken,
          false
        )
      : await fetchNavigationCached(key, locale);

    const root = data?.Navigation?.items?.[0];
    if (!root) return { tree: DEMO_NAV_DATA, fromCms: false };

    const items = (root.navItems ?? [])
      .filter((c): c is RawNavItem => (c as RawNavItem).__typename === "NavigationItem")
      .map(toNavNode);

    if (items.length === 0) return { tree: DEMO_NAV_DATA, fromCms: false };
    return { tree: items, fromCms: true };
  } catch (error) {
    console.error("[getNavigation] Falling back to DEMO_NAV_DATA:", error);
    return { tree: DEMO_NAV_DATA, fromCms: false };
  }
}

// Static fallback nav — mirrors the CMS nav seeded by seed-nav.ts.
// Hrefs match the nested page URLs created by seed-content.ts.

export const DEMO_NAV_DATA: NavNode[] = [
  {
    key: 'products',
    label: 'Products',
    href: '/en/products',
    description: 'Our full product suite',
    children: [
      {
        key: 'cms',
        label: 'Content Management',
        href: '/cms',
        children: [
          { key: 'visual-builder',   label: 'Visual Builder',   href: '/visual-builder',   children: [] },
          { key: 'content-modeling', label: 'Content Modeling', href: '/content-modeling', children: [] },
          { key: 'localization',     label: 'Localization',     href: '/localization',     children: [] },
        ],
      },
      {
        key: 'feature-experimentation',
        label: 'Experimentation',
        href: '/feature-experimentation',
        children: [
          { key: 'feature-flags',        label: 'Feature Flags',        href: '/feature-flags',        children: [] },
          { key: 'progressive-rollouts', label: 'Progressive Rollouts', href: '/progressive-rollouts', children: [] },
        ],
      },
      {
        key: 'web-experimentation',
        label: 'Web Experimentation',
        href: '/web-experimentation',
        children: [
          { key: 'visual-editor', label: 'Visual Editor', href: '/visual-editor', children: [] },
          { key: 'stats-engine',  label: 'Stats Engine',  href: '/stats-engine',  children: [] },
        ],
      },
      {
        key: 'analytics',
        label: 'Analytics',
        href: '/analytics',
        children: [
          { key: 'analytics-reports',      label: 'Reports & Dashboards', href: '/reports',      children: [] },
          { key: 'analytics-integrations', label: 'Integrations',         href: '/integrations', children: [] },
        ],
      },
    ],
  },
  {
    key: 'solutions',
    label: 'Solutions',
    href: '/en/solutions',
    children: [
      { key: 'ecommerce',  label: 'E-Commerce',        href: '/en/ecommerce',        children: [] },
      { key: 'media',      label: 'Media & Publishing', href: '/en/media-publishing', children: [] },
      { key: 'enterprise', label: 'Enterprise',         href: '/en/enterprise',       children: [] },
    ],
  },
  {
    key: 'resources',
    label: 'Resources',
    href: '/en/resources',
    children: [
      { key: 'docs',         label: 'Documentation', href: '/en/docs',         children: [] },
      { key: 'blog',         label: 'Blog',          href: '/en/blog',         children: [] },
      { key: 'case-studies', label: 'Case Studies',  href: '/en/case-studies', children: [] },
    ],
  },
  {
    key: 'developers',
    label: 'Developers',
    href: '/en/developers',
    children: [
      { key: 'api-reference', label: 'API Reference', href: '/en/api-reference', children: [] },
      { key: 'sdks',          label: 'SDKs',          href: '/en/sdks',          children: [] },
      { key: 'github',        label: 'GitHub',        href: 'https://github.com/episerver', openInNewTab: true, children: [] },
    ],
  },
  {
    key: 'company',
    label: 'Company',
    href: '/en/company',
    children: [
      { key: 'about',   label: 'About',   href: '/en/about',   children: [] },
      { key: 'careers', label: 'Careers', href: '/en/careers', children: [] },
      { key: 'contact', label: 'Contact', href: '/contact',    children: [] },
    ],
  },
];
src/app/api/webhooks/route.ts
import { type NextRequest, NextResponse } from "next/server";
import { revalidatePath, revalidateTag as _revalidateTag } from "next/cache";
import { CACHE_TAGS } from "@/lib/optimizely/cacheProfile";
import { isValidRevalidateSecret } from "@/lib/security/verifySecret";

// Next.js 16 requires a second `profile` arg in its types, but route handlers
// have no valid profile to pass (updateTag is Server Actions only). Cast to the
// single-arg overload so the runtime's immediate-invalidation path is used.
const revalidateTag = _revalidateTag as (tag: string) => void;

export async function GET() {
  return NextResponse.json({ ok: true });
}

/**
 * Optimizely Graph webhook receiver.
 *
 * Register this URL via `npm run webhook:register` — the script appends
 * ?secret=OPTIMIZELY_REVALIDATE_SECRET to the registered URL, since Graph
 * webhook registration only controls the URL, not custom headers.
 *
 * Webhook payload types:
 *   - bulk.completed  – Graph finished processing a content sync
 *   - doc.updated     – a single content item was updated
 *   - doc.expired     – a content item reached its StopPublish date
 */
export async function POST(request: NextRequest) {
  const secret =
    request.nextUrl.searchParams.get("secret") ??
    request.headers.get("x-revalidate-secret");

  if (!isValidRevalidateSecret(secret)) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }

  try {
    const body = await request.json();

    console.log("[Optimizely Graph Webhook] Event received:", body);

    revalidatePath("/", "layout");
    for (const tag of Object.values(CACHE_TAGS)) revalidateTag(tag);

    return NextResponse.json({ received: true, timestamp: Date.now() });
  } catch (error) {
    console.error("[Webhook] Failed to parse body:", error);
    return NextResponse.json({ error: "Failed to parse body" }, { status: 400 });
  }
}
scripts/register-webhook.mjs
/**
 * Registers an Optimizely Graph webhook pointing to /api/webhooks.
 *
 * Usage:
 *   npm run webhook:register
 *
 * You will be prompted for your public base URL (e.g. https://your-app.vercel.app
 * or an ngrok tunnel for local testing).
 *
 * Credentials are read from .env.local:
 *   OPTIMIZELY_APP_KEY
 *   OPTIMIZELY_APP_SECRET
 *   OPTIMIZELY_REVALIDATE_SECRET (appended to the URL so /api/webhooks can verify the sender)
 */

import { createInterface } from "readline";
import { config } from "dotenv";

config({ path: ".env.local" });

const APP_KEY = process.env.OPTIMIZELY_APP_KEY;
const APP_SECRET = process.env.OPTIMIZELY_APP_SECRET;

if (!APP_KEY || !APP_SECRET) {
  console.error(
    "Error: OPTIMIZELY_APP_KEY and OPTIMIZELY_APP_SECRET must be set in .env.local"
  );
  process.exit(1);
}

const rl = createInterface({ input: process.stdin, output: process.stdout });
const question = (prompt) =>
  new Promise((resolve) => rl.question(prompt, resolve));

const baseUrl = await question(
  "Enter your public base URL (e.g. https://your-app.vercel.app): "
);
rl.close();

const REVALIDATE_SECRET = process.env.OPTIMIZELY_REVALIDATE_SECRET;
if (!REVALIDATE_SECRET) {
  console.error(
    "Error: OPTIMIZELY_REVALIDATE_SECRET must be set in .env.local — the receiver at /api/webhooks rejects unauthenticated calls"
  );
  process.exit(1);
}

const webhookUrl = `${baseUrl.trim().replace(/\/$/, "")}/api/webhooks?secret=${encodeURIComponent(REVALIDATE_SECRET)}`;
const credentials = Buffer.from(`${APP_KEY}:${APP_SECRET}`).toString("base64");

console.log(`\nRegistering webhook → ${webhookUrl}`);

const response = await fetch("https://cg.optimizely.com/api/webhooks", {
  method: "POST",
  headers: {
    Authorization: `Basic ${credentials}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    disabled: false,
    request: {
      url: webhookUrl,
      method: "post",
    },
    topic: ["*.*"],
  }),
});

const text = await response.text();

if (!response.ok) {
  console.error(`Failed to register webhook (HTTP ${response.status}):`, text);
  process.exit(1);
}

console.log("\nWebhook registered successfully!");
try {
  console.log(JSON.parse(text));
} catch {
  console.log(text);
}
console.log(
  "\nTo list all registered webhooks, run:\n" +
    "  curl -u '<app-key>:<app-secret>' https://cg.optimizely.com/api/webhooks"
);