Developer Demo

System Architecture

How Optimizely SaaS CMS, Graph, and Feature Experimentation connect to this Next.js app - request flow, flag evaluation at the edge, per-variation ISR caching, and cache invalidation on publish.

What is a headless CMS? #

A CMS (Content Management System) is where editors write, organise, and publish content. "Headless" describes how it connects to your website.

Traditional (coupled)

Browser

GET /articles

Coupled CMS server

e.g. CMS 12 - .NET controller looks up content

Razor view (MVC)

renders the full HTML

Browser

shows the HTML page

Headless (this demo)

Browser

GET /articles

Next.js app

owns the route - queries Optimizely Graph

Optimizely Graph

returns content as JSON

Next.js renders HTML

RSC, on ISR cache miss - then cached at the CDN

Where the HTML is rendered - in a coupled CMS the CMS renders the page; in a headless setup your frontend does.

Traditional ("coupled") CMS - e.g. CMS 12 / CMS 13

One system handles both authoring and rendering. Optimizely CMS 12 and CMS 13 are examples of this model.

  • -Editors write content in the CMS admin.
  • -The CMS generates the HTML and sends it to visitors directly.
  • -Changing the look of the site means changing CMS themes or templates.
  • -Content is locked to one presentation - it can only appear on the site the CMS controls.

Headless CMS (this demo)

The CMS stores and manages content. A separate frontend (this Next.js app) fetches that content via API and decides how to display it.

  • -Editors write content in Optimizely CMS.
  • -Content is delivered as structured data via Optimizely Graph (a GraphQL API) - not as HTML.
  • -This Next.js app fetches that data, renders it, and sends HTML to visitors.
  • -The same content can power a website, a mobile app, and an email newsletter - all from one source.
  • -Developers and editors work independently - no code deployment is needed when content changes.

The building blocks of a headless site #

A headless CMS site has more moving parts than a traditional coupled CMS because the concerns are separated. Here is every piece you need and what it does.

1

Headless CMS

Optimizely SaaS CMS in this demo

Where editors create, manage, and publish content. Stores content as structured data - text, images, references, rich text. Generates no HTML and knows nothing about how content will be displayed. Exposes content via a management API and fires webhooks when content is published.

2

Content Delivery API

Optimizely Graph in this demo

A read-optimized, globally distributed API that makes CMS content queryable by the frontend. In this demo it is a GraphQL API at cg.optimizely.com with its own CDN cache layer. The frontend queries this - not the CMS management API directly - because the delivery API is built for high-traffic reads, not authoring operations.

3

Frontend application

Next.js in this demo

The application that fetches content from the delivery API and renders it into HTML for visitors. Entirely your code, hosted wherever you choose. Because it is separate from the CMS, it can be rebuilt, rewritten, or replaced without touching the CMS or migrating content. The same CMS content can simultaneously serve a web app, a mobile app, and a third-party integration.

4

Frontend hosting and CDN

Vercel in this demo

Where the frontend application runs and where rendered pages are cached. The CDN stores pre-rendered copies of pages at edge nodes close to users and serves them in ~10-50ms without involving the frontend server. Any hosting provider that supports your framework works here - Vercel, Netlify, Cloudflare Pages, AWS, and others all handle this pattern.

5

Cache invalidation via webhook

Optimizely Graph fires a POST to /api/webhooks

When an editor publishes content, something needs to tell the frontend its cached pages are out of date. This is done via webhook - Optimizely Graph sends a POST request to the frontend's webhook endpoint, which marks the relevant cache entries as stale. The next visitor request triggers a background re-render with fresh content.

6

Feature Experimentation (optional)

Optimizely FX in this demo

A separate service for A/B testing and feature flags. Delivers a datafile (a JSON config) that the frontend uses to decide which variation of content or UI to show each user. Optional - a headless CMS site does not require it, but when you want to run experiments or roll out features to a subset of users, this is the layer that handles it.

Architecture Diagram #

Request flow left to right. Edge Middleware evaluates FX flags and rewrites the URL before the CDN cache is checked - each variation gets its own ISR cache entry. CMS publishes sync into Graph, which fires a webhook to invalidate the ISR cache. SDK docs ↗

Browservisitor · editorEdge MiddlewareFX flag eval · decideAll()__v_ URL rewriteEdge CDNISR cacheper-variation entryNext.js ServerApp Router · RSCISR · revalidate: 3600Optimizely Graphcg.optimizely.comGraphQL delivery APIOptimizely CMSauthoring UIVisual Buildercdn.optimizely.comFX datafile · 60s cachebucketing events APIHTML responseHTTPSdatafile__v_ URLISR missGraphQLcontentcontent syncGraph webhookbucketing event
HTTPS request
HTML response
Rewritten __v_ URL · CDN miss forward to Next.js
GraphQL query · content response
FX datafile · bucketing event
CMS content sync on publish
Graph webhook - ISR cache invalidation

System Components #

What each box in the diagram is responsible for.

Edge Middleware

  • -Runs at the CDN edge before the cache is checked - on Vercel this is the Edge Runtime (V8 isolate), on Cloudflare it would be a Worker, on Akamai an EdgeWorker.
  • -Fetches the Optimizely FX datafile (JSON) from cdn.optimizely.com. The fetch is edge-cached for 60s so each edge node only re-fetches the datafile once per minute.
  • -Creates a user context from cookies (optimizelyEndUserId, demo_persona, demo_bucketing_id) and runs decideAll() with DISABLE_DECISION_EVENT - flag decisions only, no impression tracking yet.
  • -Rewrites the URL with one path segment per active flag: /path/__v_flagKey--variationKey. Segments are sorted so the same set of active flags always maps to the same URL and the same CDN cache entry.

Edge CDN / ISR Cache

  • -Each __v_-rewritten URL is a separate CDN cache entry. Base users and each variation are cached independently at the same path hierarchy.
  • -TTL: 60 seconds (set by export const revalidate = 60 in the catch-all route). Any CDN that supports path-based caching can serve this - no custom cache configuration needed.
  • -Busted on publish: the Graph webhook calls revalidatePath("/", "layout") + revalidateTag("page") which marks all entries as stale.
  • -Warm cache hits are served in ~10-50ms from the edge - the Next.js server is not involved.

Next.js Server

  • -Renders CMS pages with ISR. No cookies() or headers() calls anywhere in the server render tree - these would force cache-control: no-store globally.
  • -Extracts variation keys from the URL slug via extractVariations(slug) - reads __v_ segments, no SDK call needed.
  • -Queries Optimizely Graph with a variation filter to get the correct CMS content variation.
  • -FX-driven UI (banner, CTA button colour) is handled by client components that read cookies after hydration.

Optimizely Graph

  • -GraphQL delivery API at cg.optimizely.com. Serves CMS content, navigation, and banners.
  • -Accepts a variation filter so a single query can return either the base content or a specific named variation.
  • -Has its own CDN cache layer independent of Next.js. Bypass with ?cache=false for preview/seed scripts.
  • -Fires a webhook to /api/webhooks on every content change: bulk.completed, doc.updated, doc.expired.

Optimizely CMS

  • -Authors create and manage pages, blocks, and navigation in Visual Builder.
  • -CMS Variation names must exactly match FX variation key strings (case-sensitive). A mismatch means the variation content is never served.
  • -On publish: content syncs to Optimizely Graph. Graph fires a webhook to trigger ISR invalidation.

cdn.optimizely.com

  • -Serves the FX SDK datafile (JSON) - fetched by Edge Middleware on every request that doesn't hit the 60s edge cache.
  • -Receives bucketing events from the browser SDK (FxBucketingEvent component). These events are what appear in Optimizely's Results tab.
  • -The split: middleware fetches the datafile and evaluates flags (no events), the browser fires the event for the specific flag that was active on the page.

Who Interacts and How #

Two distinct actors drive the system - content editors on the authoring side, and visitors on the delivery side. They never share a runtime.

Content Editor

Works entirely inside Optimizely CMS. Never interacts with the Next.js app directly.

  • -Opens Visual Builder - edits page composition, block content, and navigation.
  • -Creates CMS Variations to match FX flag variation keys. The variation name must match exactly (case-sensitive).
  • -Clicks Publish. The CMS syncs the change to Optimizely Graph, which fires a POST webhook to /api/webhooks.
  • -The webhook marks the ISR cache as stale. The next visitor request triggers a background re-render. The editor does not wait for the CDN to clear.
  • -Preview mode bypasses the ISR cache entirely - the editor sees draft content via a previewToken that the app reads from the URL.

Visitor

Makes an HTTPS request. Three execution environments run on their behalf in sequence.

  • -Request hits Edge Middleware first. A UUID cookie (optimizelyEndUserId) is set on first visit and persists for one year - this is the stable bucketing ID.
  • -Middleware rewrites the URL with variation segments and the CDN is checked. A warm cache hit returns the page in ~10-50ms with no server involvement.
  • -On a cache miss, the Next.js server renders the page from Graph data and caches the result. The visitor receives the same HTML either way.
  • -After the HTML arrives, React hydrates in the browser. Client components (banner, CTA button) read cookies and apply personalisation - no server round-trip.
  • -The browser SDK fires a bucketing event to cdn.optimizely.com for the active flag. This is the impression that appears in the FX Results tab.

Request Flow #

What happens between a browser request and the page appearing, step by step. SDK docs ↗

1

Browser sends HTTPS request

e.g. GET /en/investments/stocks-isa - arrives at Vercel's edge network.

2

Edge Middleware evaluates FX flags

Fetches the FX datafile from cdn.optimizely.com (edge-cached for 60s). Reads user context from cookies (optimizelyEndUserId, demo_persona, demo_bucketing_id). Calls decideAll([DISABLE_DECISION_EVENT]) - no bucketing events yet. Rewrites the URL with active variation segments sorted for a stable cache key, e.g. /en/investments/stocks-isa/__v_homepage--variation_1/__v_cta--on.

3

Edge CDN checks the ISR cache

The rewritten URL is looked up. Cache HIT: the ISR-cached page is returned to the browser in ~10-50ms. Cache MISS: the request is forwarded to the Next.js server.

4

Next.js renders the page (ISR miss only)

The catch-all route extracts variation info from the URL slug - no cookies() or headers() calls. Queries Optimizely Graph with a variation filter to fetch the matching CMS content variation. Renders the page with export const revalidate = 60. The rendered output is stored in the CDN cache.

5

HTML returned to browser

The response is served - from CDN on a hit, from Next.js on a miss. The browser receives identical HTML either way.

6

Browser fires FX bucketing event

After hydration, the FxBucketingEvent component runs decide(flagKey, []) via the browser SDK. The empty options array means the bucketing event is NOT suppressed - it is recorded in Optimizely's results. This is the one event per flag per page load that attributes the impression.

Publish Flow #

What happens when an editor publishes content in the CMS. For webhook endpoint details and ISR revalidation tag strategy, see the Caching demo.

1

Editor publishes in Optimizely CMS

Content is saved. The CMS begins syncing the change to Optimizely Graph.

2

Graph indexes the content

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

3

Graph fires a POST webhook to /api/webhooks

A small JSON payload signals that content changed. The webhook handler calls revalidatePath("/", "layout") and revalidateTag() for page, navigation, banner, and quotes. Nothing is re-rendered yet - entries are just marked stale.

4

Next visitor gets the stale version instantly

ISR always serves the existing cached page first. The visitor does not wait. In the background Next.js re-renders the page with fresh data from Graph.

5

All subsequent requests get the updated page

The newly rendered output is stored in the CDN. No redeploy needed.

The Cache Layers #

A request can be answered at any of five layers between the visitor and the CMS. Each layer down adds latency, and each has its own invalidation story. This is the conceptual map - the ISR Caching demo covers the mechanics (tags, TTLs, and code).

1

Browser HTTP cache

~0ms - invalidated by hashed filenames

Hashed static assets (JS, CSS) are cached immutably in the visitor's browser - a new deploy produces new filenames, so stale assets are never served. HTML documents are not long-cached by the browser; every navigation revalidates against the CDN.

2

CDN edge / ISR full-route cache

~10-50ms - invalidated by the Graph webhook

Stores the fully rendered HTML of every page, one entry per __v_ variation URL, with a 60 second TTL. A warm hit is served from the nearest edge node without touching the Next.js server. This is the layer that absorbs almost all visitor traffic.

3

Next.js Data Cache

saves the Graph round-trip - invalidated by revalidateTag()

A fetch-level cache inside the Next.js server, keyed per query with revalidate and tags (page 60s, navigation 300s). When a page re-renders, tagged data that is still fresh is reused instead of re-querying Graph. The webhook handler calls revalidateTag() to mark entries stale on publish.

4

Optimizely Graph CDN cache

independent of this app - managed by Optimizely

Graph runs its own CDN cache in front of its content index at cg.optimizely.com. Even when the Next.js Data Cache misses, a repeated query is often answered from Graph's cache rather than its index. Preview and seed scripts bypass it with ?cache=false.

5

Source of truth

CMS content, synced into Graph's index on publish

The bottom of the stack. Content lives in the CMS and is synced into Graph's index when an editor publishes. The CMS itself never serves visitor traffic. A full ISR-miss render (Next.js render plus Graph query) costs a few hundred milliseconds - and only happens on the first request for a URL or after invalidation.

One cache sits outside this stack

The FX datafile edge cache. Edge Middleware fetches the flag configuration JSON from cdn.optimizely.com with a 60 second edge cache, so flag evaluation adds no measurable latency to the request path - it runs before the ISR cache lookup, not behind it.

What Happens When Something Is Down #

Separated concerns mean partial failure instead of total failure. What each outage actually does to the site - for the code-level patterns (error boundaries, fallbacks, not-found), see the Error Handling demo.

CMS down or in maintenance

  • -Graph keeps serving from its own index - it does not read from the CMS at request time.
  • -Visitors see no difference: ISR pages keep rendering with data from Graph.
  • -Editors are blocked from authoring until the CMS is back. Nothing published is lost.

Optimizely Graph unreachable

  • -Warm pages keep serving from the CDN ISR cache - a cache hit never queries Graph.
  • -When a render does happen, query modules catch the failure and return static fallback data instead of throwing: GetNavigation.ts returns DEMO_NAV_DATA with fromCms: false, so the site chrome never disappears.
  • -Page content queries without a fallback surface to Next.js error boundaries rather than crashing the whole site.

Webhook missed or delayed

  • -The revalidate TTL is the backstop: page caches expire after 60 seconds, navigation after 300.
  • -Worst case, visitors see content that is about a minute stale - then the next request triggers a background re-render with fresh Graph data.
  • -No manual intervention needed; the system self-heals on the next TTL expiry.

FX datafile unreachable

  • -Middleware fetches the datafile with a 3 second timeout and never fails the request - any FX error falls through to serving the page unmodified.
  • -No variation rewrite happens, so every visitor gets the original (base) content until the datafile is reachable again.
  • -Experiments pause gracefully: no impressions are recorded for the gap, but nothing breaks for visitors.

Media Has Its Own Delivery Path #

Images and other assets do not flow through Graph or the ISR HTML cache - they are delivered on a separate path with an independent cache lifecycle. See the Media & DAM demo for the asset workflow.

  • -Graph returns only the image URL, never the bytes - the rendered HTML contains an image tag pointing at the asset host.
  • -The browser fetches assets from the Optimizely asset hosts (*.cms.optimizely.com and *.cmp.optimizely.com - the allowed remotePatterns in next.config.ts).
  • -next/image sits in front: it resizes, converts to modern formats, and caches the optimized result on the hosting CDN.
  • -Consequence: publishing new content invalidates page HTML, but images keep serving from their own cache - media and pages age independently.

A/B Testing, Experimentation & Personalization #

Three related concepts that all mean the same thing at a high level: different users see different content. They differ in how a user is assigned to a version.

A/B Testing

The simplest form. Two versions of a page (A and B) are shown to two random groups of users. You measure which version results in more sign-ups, clicks, or purchases - then keep the winner.

  • -Purely random - no targeting logic.
  • -Users are split 50/50 (or any ratio you set).
  • -The goal is statistical evidence: does version B outperform A?
  • -Once you have a winner, you roll it out to everyone.

Experimentation

A more powerful form of A/B testing. Instead of just two versions, you can have multiple variations and control exactly who is eligible to see them.

  • -Multiple variations, not just A and B.
  • -Controlled rollout - show to 10% of users first, then expand.
  • -Target by attributes: device type, location, persona, plan tier.
  • -Optimizely Feature Experimentation (FX) is this layer in the demo.

Personalization

Not random at all. Content is shown to a specific user because of something you already know about them - not because they were randomly drawn into a group.

  • -A returning business customer sees business-focused content.
  • -A first-time visitor sees an introductory message.
  • -Assignment is deterministic: the same user always gets the same content.
  • -In this demo: the demo_persona cookie controls which CMS variation is served.

Same mechanism, different assignment logic

In this demo, both experimentation and personalization are served by the exact same technical mechanism: the Edge Middleware encodes a variation name into the URL (e.g. __v_homepage--variation_1), the CDN caches each variation URL separately, and Next.js fetches the matching CMS Variation from Graph. The only difference is what drives the variation name. For FX flags it is a random bucket assignment based on the user's stable ID. For the demo_persona cookie it is the cookie value itself. Both paths produce a variation key that goes into the URL - the rest of the system is identical.

Why this architecture? #

The design decisions behind how this system is built, and why they were made.

The core problem: caching pages that differ by user

The naive approach - and why it fails

The obvious way to show different content to different users is to read a cookie on the server and render the appropriate version. The problem: CDNs cache by URL. Two users requesting the same URL get the same cached page regardless of their cookies. To fix this you would need to configure the CDN to vary its cache by cookie - which requires CDN-specific rules, disables most cache optimisations, and often results in every request hitting the server anyway.

This architecture's solution

  • -Evaluate which variation a user should see at the edge - before the CDN cache is checked.
  • -Encode the result in the URL: /savings/__v_homepage--variation_1
  • -The CDN caches by URL, so each variation gets its own independent cache entry.
  • -Next.js only renders a page on the FIRST request for each variation URL.
  • -All subsequent requests are served from CDN cache in ~10-50ms - no server involved.

Why Optimizely Graph is a separate layer

The CMS authoring API is designed for editors - it is single-region, write-heavy, and not built to handle thousands of simultaneous visitors. Optimizely Graph is a separate globally-distributed GraphQL delivery layer. It indexes content from the CMS, adds its own CDN cache, supports filtering by variation and locale in a single query, and is purpose-built for read performance at scale. Think of the CMS as your database and Graph as the read replica optimized for your frontend queries. This also means the CMS can go into maintenance mode without affecting visitors - Graph keeps serving content from its own index.

Execution Environments #

Three separate runtimes execute code on behalf of a visitor request. Each has a different set of APIs, a different view of the request, and a different relationship to the cache.

Middleware

Edge Runtime - every request

When
Every request, before the CDN cache is checked. Runs even on cache hits.
Where
V8 isolate on CDN edge nodes. No Node.js APIs (no fs, no process.env at runtime). Cold starts in under 1ms.
Can read
Cookies, User-Agent, full request URL. Cannot call cookies() or headers() from next/headers.
What it does
Fetches the FX datafile, runs decideAll(), rewrites the URL with __v_ variation segments.
Optimizely SDK
@optimizely/optimizely-sdk/universal - the Edge-compatible build with no Node.js dependencies.
Cache impact
None. Middleware runs before the CDN cache lookup and does not write to it.

Next.js Server

Node.js - ISR miss only

When
Only on an ISR cache miss: the first request to a URL, or after a webhook marks it stale.
Where
Node.js process (serverless function or long-running server). Full Node.js APIs available.
Can read
URL params (the slug including __v_ segments). Must NOT call cookies() or headers() - any use in the render tree forces cache-control: no-store on the whole response.
What it does
Calls extractVariations(slug) to recover the variation keys. Queries Optimizely Graph with a variation filter. Renders React Server Components to HTML.
Optimizely SDK
None. The variation is already encoded in the URL from middleware - no SDK call needed server-side.
Cache impact
Writes the rendered HTML to the CDN cache with a 60s TTL. All subsequent requests for that URL are served from the CDN.

Browser

Client-Side - after hydration

When
After the browser receives HTML and React hydrates. Runs on every page load, including cache hits.
Where
Visitor's browser. Full Web APIs available (fetch, document.cookie, localStorage).
Can read
document.cookie (optimizelyEndUserId, demo_persona). The cookie is intentionally NOT httpOnly so the browser SDK can read it for stable bucketing.
What it does
UI personalisation (banner copy, CTA button color) via client components that read cookies in useEffect. Fires the FX bucketing event via decide(flagKey, []).
Optimizely SDK
@optimizely/optimizely-sdk (browser build, resolved automatically by the bundler). A module-level singleton so the datafile is fetched once per page load.
Cache impact
None. CSR runs in the browser and does not affect the CDN cache or ISR state.

CDN compatibility

This demo deploys to Vercel, but the ISR-via-URL-rewrite pattern works with any CDN that supports path-based cache keys. The constraint is simple: the CDN must treat /savings/__v_homepage--variation_1 and /savings/ as separate cache entries. That is standard behaviour - Netlify, AWS CloudFront, Cloudflare, Akamai, and Fastly all do this out of the box. The reason the variation is encoded in the URL (not a cookie or a custom request header) is that most CDNs do not vary their cache by cookie or arbitrary header by default, and configuring them to do so requires CDN-specific rules (e.g. Akamai vary-by-header, CloudFront cache policies). A URL-based key needs no CDN configuration at all.

Key Terms #

Definitions of the technical terms used throughout this page.

CDNContent Delivery Network

A global network of servers that cache copies of your pages close to users. Instead of every request travelling to one central server, it is served from the nearest node - reducing latency from hundreds of milliseconds to tens.

Edge

Servers at CDN edge nodes - physically distributed around the world, close to users. Running code at the edge means it executes at these locations rather than a central server. Latency can drop from ~200ms to under 5ms.

ISRIncremental Static Regeneration

A Next.js feature. Pages are pre-rendered to static HTML and cached. They serve instantly from cache. After a set time (or on demand via webhook), the page is regenerated in the background with fresh data - no redeploy needed.

GraphQL

A query language for APIs. Instead of many fixed endpoints (like REST), you send one query describing exactly the data you want and get exactly that back - no over-fetching, no under-fetching. Optimizely Graph exposes its content delivery API via GraphQL.

Webhook

When something happens in one system, it automatically sends an HTTP POST request to notify another system. When an editor publishes in Optimizely CMS, Optimizely Graph fires a webhook to tell this Next.js app to invalidate its cache.

Feature Flag

A named switch in Optimizely Feature Experimentation that controls which variation of a feature or page a user sees. Flags can be simple on/off toggles or have multiple named variations with different rollout percentages.

Variation

One specific version of content. A homepage might have a control (original) and variation_1 (different hero image). Each CMS Variation maps to a separate CDN cache entry via the __v_ URL segment.

Bucketing

The process of assigning a user to a variation. Based on a stable user ID (the optimizelyEndUserId cookie) so the same user always sees the same variation. The assignment rules live in the FX datafile.

Datafile

A JSON file published by Optimizely FX containing all flag configurations and variation rules. Edge Middleware fetches this file (edge-cached for 60s) and evaluates flags locally - no remote API call needed on every request.

RSCReact Server Components

A Next.js and React feature where components render on the server and send HTML to the browser instead of JavaScript that runs client-side. Used for CMS content rendering so the page arrives pre-rendered and cacheable.