Developer Demo

Experimentation

Optimizely Feature Experimentation runs alongside SaaS CMS on the same platform. Flag decisions are evaluated in edge middleware, before the page renders. Carry the variation key in the request path and each variation caches independently. Decision events fire client-side after the user sees the variation, connecting A/B experiments directly to editor-created content variations.
✓ Server-side decisionsFeature flags · ExperimentsAudience targetingVariable deliveryCMS Variations integrationVariation Container block

Two Ways to Experiment #

Optimizely Experimentation offers two mechanisms for testing content. They differ in where the decision runs: Feature Experimentation decides on the server and integrates with CMS content variations through Graph; Web Experimentation & Personalization is a standalone product with its own visual editor that decides in the browser, separate from the CMS and Graph. This page covers the server-side path end to end.

Feature Experimentation - server-side

this demo

Edge middleware evaluates the flag, carries the variation key forward on the request, and Graph returns the matching CMS content variant. Put that key in the request path and every variation gets its own ISR cache entry; put it in a header instead and the wiring is simpler, but all variations share one entry unless the CDN is told to vary on it. A small client component fires the decision event after the visitor sees it.

+Server-rendered - no flicker, full ISR caching per variation
+Drives real CMS content variations via the Graph variation filter

Web Experimentation & Personalization - client-side

not covered here

Best for fast in-browser changes; the wrong tool when you need CMS-authored, server-rendered content. A separate, standalone Optimizely product (standalone Personalization is the same engine sold on its own) with its own visual editor and its own client-side delivery - it buckets visitors and mutates the page in the browser, independent of the CMS and Graph. Its upside: it can change anything on the rendered page, even edits never modeled in the CMS, without a developer exposing a field or component first.

Routing a WX bucket to a real CMS variation needs a bridge - the page reads the decision before first paint and routes to the CMS variation on the same pageview. See the Web Experimentation bridge.

+Own visual editor - marketers ship tests without a code change
+Change anything on the page - no developer needed to pre-model it in the CMS
-Content lives in WX, not your CMS - wrong tool for CMS-authored content

How It All Fits Together: Configure, Then Serve #

The full lifecycle has two phases. Configure is one-time setup done entirely in the FX and CMS UIs - no code. Serve is what runs automatically on every page request. Once the integration code is in place, editors and experimenters add variations to any page without a developer ever touching the codebase.

Configure
1

Create the CMS variation

In Visual Builder, add a variation and name it to match the FX variation key.

2

Create the flag + rule

In FX, create the flag and a delivery rule. Set variation variables cms_flag: true and cms_route.

3

Create + attach audience

Define an audience from SDK attributes or ODP segments and attach it to the delivery rule.

4

Set traffic + enable

Allocate traffic and turn the rule on. FX now buckets visitors.

Serve
Edge5

Pick the variation, put it in the URL

FX chooses which variation this visitor should see (that choice is the “decision”), and the middleware writes it into the request path so the page can be cached per variation.

decideAll([DISABLE_DECISION_EVENT])
// → { checkout_layout: { variationKey: 'single_step' } }
// rewrite: /pricing → /pricing/__v_checkout_layout--single_step
at the edge, before render→ code
Server · Graph (cached)6

Fetch the matching content from Graph

The page asks Optimizely Graph for the content variant whose name matches the chosen variation. The response is cached, so repeat visits stay fast.

variation: {
  include: 'SOME',
  value: ['single_step'],
  includeOriginal: true,
}
in the content route→ code
Client7

Record that the visitor saw it

In the browser, FX logs a “decision event” - the event that tells the experiment this visitor was shown this variation, so results can be measured.

decide('checkout_layout')
in the browser, after render→ code

CMS Variations - Connecting FX to Content #

FX variation keys and CMS content variations share a single string contract. When Graph receives an active variation key, it looks for a CMS content variant with the same name and returns it. Editors create the variant in Visual Builder; the SDK wires it at runtime - no code change required after initial setup.

The contract: one string, three places

this demo's homepage personalization

FX variation key

flag homepage - the SDK buckets the user into

business

Graph filter value

variation: { include: SOME, value: [ … ] }

business

CMS variation name

Visual Builder serves the variant named

business

or the original if no match

The same business string is the only link between FX and the CMS - match it exactly (case-sensitive) and Graph serves the right variant.

Graph query (business variation active)
# The variation key becomes a Graph filter. For a visitor bucketed into "business":
query {
  _Content(
    where: { _metadata: { url: { default: { eq: "/en/" } } } }
    variation: { include: SOME, value: ["business"], includeOriginal: true }
  ) {
    items {
      _metadata { variation }   # "business", or null for the base page
    }
  }
}

Your Variation Keys → Graph Filter

This is what the live page route is passing to getContentByPath for your session right now.

variationOption = {
  variation: {
    include: "SOME",
    value: ["semantic","rate_led"],
    includeOriginal: true,
  },
  stored: false,
}

Your live session right now

User ID6c9c40a0…23c0stable per browser
Devicedesktopderived from User-Agent header (no cookie)
Bucketing ID-sign in via Audience Switcher to set
Active Variationssemantic, rate_ledkeys passed to Graph (the filter above)

Variation Container: Experiment on Part of a Page #

A CMS variation swaps the whole page. When an experiment only touches one region - an offer, a call to action, a pricing teaser - the Variation Container block keeps it there. Editors add one Variation per FX variation key, each built from ordinary blocks, inside the page itself.

Both content areas are type: "content", so Graph returns the container and every variation inline in the page's own query. There is no Visual Builder variation to create and no Graph variation filter: the app picks which variation to render from data it already has.

The content model

Variation Container
├── flagKey     savings_offer
└── variations
    ├── Variation  control    ← fallback
    │   └── blocks  Heading, CTA
    ├── Variation  rate_led
    │   └── blocks  Heading, CTA
    └── Variation  bonus_led
        └── blocks  Heading, CTA

The variation key is the same one-string contract as a CMS variation name: it must match the FX variation key exactly, and it is case-sensitive. The first Variation is the fallback - it shows when the flag is off, the visitor is not bucketed, or no key matches.

Two content types
// src/components/blocks/VariationItemBlock/index.tsx (simplified)
export const VariationItemBlockType = contentType({
  key: "VariationItemBlock",
  displayName: "Variation",
  baseType: "_component",
  properties: {
    variationKey: { type: "string", isRequired: true },   // = the FX variation key
    blocks: {
      type: "array",
      items: {
        type: "content",                 // inline-expanded by Graph in the page query
        allowedTypes: ["_component"],
        restrictedTypes: [...CONTENT_AREA_EXCLUDES, "VariationContainerBlock"],
      },
    },
  },
});

// src/components/blocks/VariationContainerBlock/index.tsx (simplified)
export const VariationContainerBlockType = contentType({
  key: "VariationContainerBlock",
  displayName: "Variation Container",
  baseType: "_component",
  compositionBehaviors: ["sectionEnabled"],   // a content area rules out elementEnabled
  properties: {
    flagKey: { type: "string", isRequired: true },
    // The first Variation is the fallback
    variations: { type: "array", items: { type: "content", allowedTypes: [VariationItemBlockType] } },
  },
});
Render
Edit mode1

Every variation, stacked and labelled

In Visual Builder there is no decision. Each variation shows under a label with its key, the first marked as the fallback, so editors can build and preview all of them.

__context.edit
// → all variations
Edge · server2

Middleware decided: render one variation

When the flag sets cms_flag: true and a cms_route covering this page, middleware appends the same __v_ segment it uses for page variations. The block renders only that variation: no flicker, one ISR entry per variation.

/savings-offer
// → /savings-offer/__v_savings_offer--rate_led
Browser3

No segment: the browser decides

Any other flag works too. Every variation is server-rendered into the payload, the fallback paints first, and useFxDecision swaps in the visitor's variation once the datafile loads.

useFxDecision('savings_offer')
// → { variationKey: 'bonus_led' }
Choosing the variation
// src/lib/optimizely/requestVariations.ts - a per-request store
const store = cache(() => ({ flagVariations: [] as FlagVariation[] }));
export function setRequestFlagVariations(v: FlagVariation[]) { store().flagVariations = v; }
export function edgeVariationFor(flagKey: string) {
  return store().flagVariations.find((fv) => fv.flagKey === flagKey)?.variationKey ?? null;
}

// src/app/[[...slug]]/page.tsx - the catch-all records the middleware's __v_ segments
const { flagVariations } = extractVariations(slug);
setRequestFlagVariations(flagVariations);

// VariationContainerBlock - a server component
const edgeKey = edgeVariationFor(flagKey);
if (edgeKey !== null) {
  // Middleware decided this flag: render that variation only, no flicker
  const shown = variations.find((v) => v.variationKey === edgeKey) ?? variations[0];
  return <>{render(shown)}<FxBucketingEvent flagKey={flagKey} /></>;
}
// No segment: server-render every variation, let the browser pick one
return (
  <VariationContainerClient
    flagKey={flagKey}
    variants={variations.map((v) => ({ key: v.variationKey, node: render(v) }))}
  />
);

// VariationContainerClient - "use client"
const decision = useFxDecision(flagKey);   // null until the datafile loads
const shown = variants.find((v) => v.key === decision?.variationKey) ?? variants[0];

Try it: the seeded Savings Offer page

CMS variation (page)Variation Container (block)
ScopeThe whole page - one composition per variationOne region; the rest of the page is shared
Authored inVisual Builder → Add variation, a full copy of the page eachInside the page, one Variation block per key
Created through the APINo - Visual Builder onlyYes - ordinary content, so it can be seeded
Graph queryVariation filter: include SOME, includeOriginal, stored: falseThe plain page query - every variation inline
Picks the variationGraphThe app - on the server or in the browser
CachingOne ISR entry per variationEdge: one per variation · Browser: one for all
FlickerNoneEdge: none · Browser: the fallback paints first
PayloadThe served variation onlyEdge: the served variation · Browser: every variation
Decision eventFired by the catch-all routeFired by the block itself
1

Create the flag and its variations in Feature Experimentation

One variation per arm - the demo flag savings_offer has control, rate_led and bonus_led - and an A/B rule. For the edge path, give each variation the variables cms_flag: true and a cms_route covering the page. Without them the block still works; the browser decides instead.
2

Add a Variation Container to the page

In Visual Builder, add it as a section (or drop it into a Traditional Page's Main Content) and type the flag key exactly.
3

Add one Variation per variation key

Create a Variation for each FX variation key and build its content from any blocks. Put the control first: the first Variation is the fallback for visitors the flag does not bucket.
4

Publish and start the rule

No code change and no Visual Builder variation. FX allocates traffic and reports results; the block renders whichever variation each visitor is bucketed into.
The block fires the decision event itself. It mounts FxBucketingEvent for its flag whenever it renders a decided variation - including the off arm of an A/B rule, so the control group is counted. If the catch-all route also reports the same flag on that page, FxBucketingEvent still sends one impression per page and flag.
The Variation Container implementation3 files
src/components/blocks/VariationContainerBlock/index.tsx
import { contentType } from "@optimizely/cms-sdk";
import { OptimizelyComponent, getPreviewUtils } from "@optimizely/cms-sdk/react/server";
import { VariationItemBlockType, type VariationItemData } from "@/components/blocks/VariationItemBlock";
import { BlockErrorBoundary } from "@/components/cms/BlockErrorBoundary";
import { FxBucketingEvent } from "@/components/FxBucketingEvent";
import { asSdkComponentContent, asSdkContent } from "@/components/cms/sdkTypes";
import { edgeVariationFor } from "@/lib/optimizely/requestVariations";
import type { Placement } from "../_shared/displayTemplateSettings";
import { VariationContainerClient } from "./VariationContainerClient";

// A block-level FX experiment. Editors build one Variation per FX variation key, out of
// any blocks, and Graph inline-expands all of them in the page's own query - no CMS
// (Visual Builder) variation and no Graph variation filter. This component then picks
// which one to render:
//
//   1. Edit mode   - every variation, stacked and labelled, so each can be edited.
//   2. Edge        - middleware decided the flag for this request (cms_flag + a
//                    cms_route covering this page) and the catch-all recorded the
//                    __v_ segment: render that variation on the server. No flicker,
//                    and each variation is its own ISR entry.
//   3. Browser     - no segment: VariationContainerClient decides with useFxDecision,
//                    showing the fallback until the datafile loads.
//
// The first variation is the fallback whenever nothing matches.
export const VariationContainerBlockType = contentType({
  key: "VariationContainerBlock",
  displayName: "Variation Container",
  baseType: "_component",
  // sectionEnabled only: a block with a content area cannot be elementEnabled.
  compositionBehaviors: ["sectionEnabled"],
  properties: {
    flagKey: {
      type: "string",
      displayName: "FX flag key",
      description: "The Feature Experimentation flag that decides which variation a visitor sees. Must match exactly.",
      isRequired: true,
      indexingType: "queryable",
    },
    variations: {
      type: "array",
      displayName: "Variations",
      description:
        "One Variation per FX variation key. The first is the fallback: shown when the flag is off, the visitor is not bucketed, or no key matches.",
      items: { type: "content", allowedTypes: [VariationItemBlockType] },
    },
  },
});

interface VariationContainerData {
  flagKey?: string | null;
  variations?: Array<VariationItemData | null> | null;
  __context?: { edit?: boolean } | null;
}

type VariationContainerBlockProps = VariationContainerData & {
  content?: VariationContainerData;
};

function keyOf(variation: VariationItemData): string {
  return variation.variationKey?.trim() ?? "";
}

// OptimizelyComponent forwards extra props to the block it renders, but its type does
// not declare them.
const VariationComponent = OptimizelyComponent as (
  props: Parameters<typeof OptimizelyComponent>[0] & { placement?: Placement }
) => ReturnType<typeof OptimizelyComponent>;

// Each Variation supplies its own page container; pass "element" where this block
// already provides one (edit mode) so it is not applied twice.
function renderVariation(variation: VariationItemData, placement?: Placement) {
  return (
    <BlockErrorBoundary>
      <VariationComponent content={asSdkComponentContent(variation)} placement={placement} />
    </BlockErrorBoundary>
  );
}

export default function VariationContainerBlock(props: VariationContainerBlockProps) {
  const data: VariationContainerData = props.content ?? props;
  const { pa } = getPreviewUtils(asSdkContent(data));
  const flagKey = data.flagKey?.trim() ?? "";
  const variations = (data.variations ?? []).filter((v): v is VariationItemData => !!v);

  if (data.__context?.edit) {
    return (
      <div data-component="VariationContainerBlock" data-variation-source="edit" className="max-w-7xl mx-auto px-8 py-8">
        <p {...pa("flagKey")} className="mb-4 text-xs font-mono text-on-surface-variant">
          Variation container · flag{" "}
          <span className="text-brand font-semibold">{flagKey || "(set an FX flag key)"}</span>
        </p>
        <div {...pa("variations")} className="space-y-6">
          {variations.length === 0 && (
            <p className="px-8 py-10 text-sm text-on-surface-variant text-center border border-dashed border-outline-variant rounded-2xl">
              Add one Variation for each FX variation key
            </p>
          )}
          {variations.map((variation, i) => (
            <div key={i} className="border border-dashed border-outline-variant rounded-2xl overflow-hidden">
              <p className="px-4 py-2 text-xs font-mono bg-surface-low text-on-surface-variant">
                Variation:{" "}
                <span className="text-on-surface font-semibold">{keyOf(variation) || "(no key)"}</span>
                {i === 0 && " · fallback"}
              </p>
              <div className="px-6">{renderVariation(variation, "element")}</div>
            </div>
          ))}
        </div>
      </div>
    );
  }

  if (variations.length === 0) return null;
  const fallback = variations[0];

  // Without a flag there is nothing to decide - and no impression to fire.
  if (!flagKey) {
    return (
      <div data-component="VariationContainerBlock" data-variation-source="fallback" data-variation-key={keyOf(fallback)}>
        {renderVariation(fallback)}
      </div>
    );
  }

  const edgeKey = edgeVariationFor(flagKey);
  if (edgeKey !== null) {
    const match = variations.find((v) => keyOf(v) === edgeKey);
    if (!match && process.env.NODE_ENV !== "production") {
      console.warn(
        `[VariationContainerBlock] flag "${flagKey}" decided "${edgeKey}" but no Variation has that key - rendering the fallback`
      );
    }
    const shown = match ?? fallback;
    return (
      <div data-component="VariationContainerBlock" data-variation-source="edge" data-variation-key={keyOf(shown)}>
        {renderVariation(shown)}
        <FxBucketingEvent flagKey={flagKey} />
      </div>
    );
  }

  return (
    <VariationContainerClient
      flagKey={flagKey}
      variants={variations.map((v) => ({ key: keyOf(v), node: renderVariation(v) }))}
    />
  );
}
src/components/blocks/VariationContainerBlock/VariationContainerClient.tsx
"use client";

import { Fragment, type ReactNode } from "react";
import { useFxDecision } from "@/lib/optimizely/useFxDecision";
import { FxBucketingEvent } from "@/components/FxBucketingEvent";

export interface RenderedVariant {
  key: string;
  /** The variation's blocks, already rendered on the server. */
  node: ReactNode;
}

// The browser path of VariationContainerBlock, used when middleware did not decide the
// flag for this request (no cms_flag, or cms_route does not cover this page). Every
// variant arrives server-rendered, so the swap is instant once the datafile loads -
// but until then this renders the fallback (variants[0]), on the server and on the
// first client render alike, which keeps hydration consistent.
export function VariationContainerClient({
  flagKey,
  variants,
}: {
  flagKey: string;
  variants: RenderedVariant[];
}) {
  const decision = useFxDecision(flagKey);
  const decidedKey = decision?.variationKey ?? null;
  const shown = variants.find((v) => decidedKey !== null && v.key === decidedKey) ?? variants[0];

  return (
    <div data-component="VariationContainerBlock" data-variation-source="browser" data-variation-key={shown.key}>
      {/* Keyed so a swap remounts the variant instead of reconciling one variant's
          blocks into another's state. */}
      <Fragment key={shown.key}>{shown.node}</Fragment>
      {/* Mounted for every resolved decision, including the "off" bucket: in an A/B
          rule "off" is a real arm, and skipping its impression would leave the
          control group out of the results. */}
      {decision && <FxBucketingEvent flagKey={flagKey} />}
    </div>
  );
}
src/lib/optimizely/requestVariations.ts
import { cache } from "react";
import type { FlagVariation } from "./variationPath";

// The FX decisions middleware encoded into this request's path (`__v_flag--variation`),
// made readable to blocks. Blocks render through <OptimizelyComponent>, which passes
// them nothing from the page, and server components have no React context - so the
// catch-all route records the segments here and VariationContainerBlock reads them.
//
// cache() makes this a per-request singleton: every server component in one render
// sees the same object, and nothing leaks between requests. ISR stays correct because
// each __v_ path is its own render and its own cache entry. The SDK's own
// setContext() can't carry this - its ContextData is a closed interface.
const store = cache((): { flagVariations: FlagVariation[] } => ({ flagVariations: [] }));

/** Called by the catch-all route before it renders the page tree. */
export function setRequestFlagVariations(flagVariations: FlagVariation[]): void {
  store().flagVariations = flagVariations;
}

/** The variation middleware decided for this flag on this request, or null. */
export function edgeVariationFor(flagKey: string): string | null {
  return store().flagVariations.find((fv) => fv.flagKey === flagKey)?.variationKey ?? null;
}

Live Demo: checkout_layout #

This card is driven entirely by the checkout_layout flag and its headline + subheadline variables.

Variations:off·control(product-led copy)·challenger(benefit-led copy + brand gradient)

checkout_layout

Flag is off - enable it in the FX dashboard to see the live hero copy variation.

Configure - Step by Step #

Once the integration code is in place (see below), editors can set up any number of content experiments without touching code. The variation key string is the only contract between Feature Experimentation and the CMS.

1

Create a flag and targeting rules in Feature Experimentation

Create manually in the FX dashboard or via the REST API. This demo uses flag homepage with five targeted-delivery rules, each serving 100% of its audience: personal (audience: persona == "personal"), business (audience: persona == "business"), mortgages (audience: persona == "mortgages"), and investments (audience: persona == "investments"). Visitors matching none fall through to the default off variation and see the base homepage. The persona attribute is set from the persona cookie. In production that attribute would come from a real signal - CRM data, a session, a segment - rather than a switcher; only the source changes, the audience conditions stay the same. On each variation, set two variation variables: cms_flag: true (marks this as a CMS-content experiment the edge should route) and cms_route (the path the variation targets, e.g. "/" for the homepage, or "/products/*" for a section). The middleware only applies a variation whose cms_route matches the current page, so you can run many CMS experiments across the site without them colliding or fragmenting each other's cache. Leave cms_route unset to apply everywhere.
2

Create variations in Visual Builder, then set their compositions

Open the homepage in the CMS Visual Builder. In the right-hand panel, click Add variation. Create two variations named exactly personal and business (case-sensitive - must match the FX variation keys exactly). Each becomes a new draft version in the CMS. Edit each variation's composition in Visual Builder and publish it - or discover the version numbers via GET /content/{key}/versions and PATCH them programmatically (see the note below).
CMS variations must be created in Visual Builder - the Management API cannot create them. The variation field exists in Graph's schema but is silently ignored by the Management API on write. However, once created in Visual Builder each variation becomes a new draft version - you can discover the version number via GET /content/{key}/versions and PATCH it with the correct composition + status: "published".
3

Validate with the Audience Switcher

Use the floating pill in the bottom-right corner to preview each variation instantly - no waiting for FX bucketing. Select Business Customer; the homepage should show “the business variant headline” once the CMS variation exists.
4

Enable the flag and start the experiment

Turn on the flag and launch the experiment in the FX dashboard. FX handles traffic allocation and bucketing; Graph handles content delivery. Analytics, winner declaration, and rollout happen entirely in the FX dashboard - no code changes needed.
5

Validate the experiment in your own app

With a variation created and the flag live, any visitor bucketed into it is served the matching CMS variant automatically - no per-page code. In your own app, confirm it end to end: check the variation segment on the rewritten request, the served content, and the participant count in your FX results. FX owns analytics, winner declaration, and rollout from here.

Audience Targeting #

An audience attached to a delivery rule (step 3) can be backed two ways: SDK attributes passed at decision time, or ODP segments the visitor already qualifies for. Both are matched on the server - the browser never knows which audience it was matched to. Either way the rule resolves to a variation key, and everything downstream (Graph filter, CMS variant, decision event) is identical. For the full breakdown of these two sources - native attributes vs the ODP behavioral layer - see Personalization.

Attribute-based audiences

this demo

Attributes like device, persona, and logged_in are collected by the request-scoped visitor context (cookies + headers) and passed into the decision. FX audience conditions match against them at bucketing time.

ODP segment audiences

An FX audience can also be defined as membership in an ODP segment (built from behavioural + profile data in the Optimizely Data Platform). A server-side segment query resolves the visitor's qualified segments, and those feed the decision the same way attributes do. See Personalization → ODP for the full walkthrough.

Mapping segments to variations by hand is one option. The SDK can also do it natively: build the client with an ODP manager and an audience carries an ODP_SEGMENT condition directly, with the SDK fetching the segments itself. Keep that fetch off the edge either way - unlike a datafile decision it is a real blocking round trip, and middleware runs on every request.

Built-in Attributes

device

read from User-Agent header server-side (no cookie - GDPR safe)

desktop
logged_in

from logged_in cookie (Audience Switcher)

false

Adding Custom Attributes

All attributes are passed when creating the user context. Merge your extras alongside the base visitor attributes - the SDK evaluates all of them against your FX audience conditions.

// All attributes go into createUserContext - no per-decide overrides.
const userCtx = client.createUserContext(userId, {
  ...attributes,           // base: device, logged_in, persona
  plan: "premium",         // from your database
  country: "GB",           // from geo header
});
const decision = userCtx.decide("my_flag", [DISABLE_DECISION_EVENT]);

FX Audience Sources: Native Attributes vs ODP Segments #

ODP is an audience layer, not a delivery engine - it plugs into FX's audience configuration to provide behavioral depth. Within the FX path, an audience condition can be fed from two sources: native request-time attributes already in your app, or ODP segments computed from cross-session behavior. Both resolve to the same variation key and run through the same FX delivery engine - they differ in what they can express and what they cost.

Native FX attributes

default · app-native

Attributes you already know at request time - device, persona, logged_in, geo, plan, UTM - are collected once per request and passed straight into userCtx.decide(). The SDK matches them against your audience rules locally, in-process - no extra service, no network round-trip.

+Zero network calls - evaluated in the same request
+One-file setup: add to visitor.ts, define the FX condition
-Only sees this request - no memory of past behavior

ODP segments

behavioral layer

ODP is a customer data platform that sits between the visitor and FX. It ingests behavioral events over time, builds a persistent per-visitor profile, and computes segments. An FX audience can reference an ODP segment; qualifying the visitor requires a network call to ODP. Use it when targeting depends on history a single request can't see - “viewed pricing 3x this week”, high-value customer, churn risk.

+Remembers behavior across sessions and devices
+Rich segments from events, not just request facts
-Network call + latency; needs event instrumentation and an ODP account
Both FX audience sources resolve to the same variation key and run the identical FX → Graph → CMS pipeline. ODP simply adds a behavioral data layer before the FX decision. For a path that bypasses FX entirely, see the ODP direct section below.

FX Native Attributes in Depth #

The native path in detail. FX audiences are matched against the attributes you return from the request-scoped visitor context, all evaluated in-process - headers, cookies, auth sessions, geo data, and any database value are available before HTML is streamed, with no network call to a separate service. Below are practical patterns for the most common attribute sources.

1

Device & User-Agent

already live

The User-Agent header is parsed server-side on every request - no cookie stored (GDPR safe). Use the device attribute to target mobile vs desktop audiences in the FX dashboard.

Your current device attribute: desktop

// src/lib/optimizely/visitor.ts
// No cookie - derived from headers() on every request
const ua = headerStore.get("user-agent") ?? "";
const device = /mobile|android|iphone|ipad/i.test(ua)
  ? "mobile"
  : "desktop";

// Pass as part of attributes to createUserContext(userId, { device, ... })
// FX audience condition: device = "mobile"
2

Persona

already live

The Audience Switcher sets a persona cookie. The request-scoped visitor context reads it and includes it in the attribute map as persona. In production, replace the cookie with a real signal - segment from your CRM, onboarding answers, or account type from a database.

Current value: not set

// src/lib/optimizely/visitor.ts
const persona = cookieStore.get("persona")?.value;

// In production: replace cookie with real enrichment
// e.g. from your CRM or database:
// const persona = await getUserSegment(userId);

// FX audience conditions:
//   persona = "personal"
//   persona = "business"
//   persona = "mortgages"
//   persona = "investments"
3

Auth session

already live

Toggle Logged In in the Audience Switcher to simulate auth state. In a real app, read your auth session directly and use the user's stable account ID as userId so bucketing is consistent across devices.

Current value: false

import { getServerSession } from "next-auth";

const session = await getServerSession();
// Use the account ID as userId for stable cross-device bucketing
const userId = session?.user?.id ?? cookieId;

const userCtx = client.createUserContext(userId, {
  ...attributes,
  logged_in: Boolean(session),
  plan:      session?.user?.plan ?? "free",
  role:      session?.user?.role ?? "guest",
});
const decision = userCtx.decide("premium_feature", [DISABLE_DECISION_EVENT]);
// FX audiences:
//   logged_in = true
//   plan = "premium"
//   role = "admin"
4

Geo / Country (request headers)

Vercel, Cloudflare, and most edge runtimes inject geo headers on every request. Add them to the request-scoped visitor context and they become available as FX audience conditions instantly.

Common use cases: region-specific promotions, GDPR consent audiences, local pricing.

// src/lib/optimizely/visitor.ts - extend with geo
import { headers } from "next/headers";

const hdrs = await headers();
const country =
  hdrs.get("x-vercel-ip-country") ??   // Vercel
  hdrs.get("cf-ipcountry") ??           // Cloudflare
  "unknown";

// Add to the attributes return value:
return {
  userId,
  attributes: { device, persona, logged_in, country },
};
// FX audience: country = "GB"
5

URL & query parameters (UTM, campaign, force-bucket)

Query params are available in Server Components via searchParams. Use them to target campaign traffic, enable QA force-bucketing, or segment by referral source - no cookie write required.

UTM parameters identify paid traffic - e.g. show a different hero to users arriving from a Google Ads campaign.

// src/app/[[...slug]]/page.tsx
export default async function CmsPage({
  params,
  searchParams,
}) {
  const sp = await searchParams;
  const userCtx = client.createUserContext(userId, {
    ...attributes,
    utm_source:   sp.utm_source ?? "direct",
    utm_medium:   sp.utm_medium ?? "none",
    utm_campaign: sp.utm_campaign ?? "none",
  });

  const decision = userCtx.decide("campaign_hero", [DISABLE_DECISION_EVENT]);
  // FX audience: utm_source = "google"
}
6

Combining attributes - audience conditions in FX

All attributes are available as AND/OR/NOT conditions in the FX dashboard. The SDK evaluates them locally against the attribute map - no network call per decision.

// All attributes are set once when creating the user context
const userCtx = client.createUserContext(userId, {
  // Base attributes (device, persona, logged_in) from the visitor context
  ...attributes,

  // From auth session
  logged_in:        Boolean(session),
  plan:             session?.user?.plan ?? "free",
  account_age_days: session?.user?.ageDays ?? 0,

  // From geo headers
  country,

  // From query params
  utm_source: sp.utm_source ?? "direct",
});
const decision = userCtx.decide("homepage", [DISABLE_DECISION_EVENT]);
// FX evaluates ALL of these server-side.
// Zero client-side data exposure.

Extending the Visitor Context #

Adding a new audience signal is a one-file change. Once an attribute flows into the request-scoped visitor context, it becomes available as an FX audience condition with no further SDK configuration.

1

Add the signal where visitor context is resolved

Open src/lib/optimizely/visitor.ts and add your attribute to the return value. Read from cookies() for persisted values, headers() for request signals like geo or referrer, or await a database or auth session call for user-specific data. The function is called once per request via React cache().
2

Register the attribute in the FX dashboard

In the Optimizely FX dashboard, go to Audiences > Attributes and add the new attribute by name. The type (string, boolean, number) must match what you return. No SDK version bump required - the datafile update propagates within 60 seconds.
3

Build an audience using the new attribute

Create a new audience in the FX dashboard with a condition on your attribute (e.g. country = "GB"). Assign the audience to a delivery rule on any flag. The string between the FX condition and your attribute key is the only coupling - it must match exactly (case-sensitive).
4

Test locally with the attribute set

For cookie-based attributes, set the cookie value directly in browser DevTools and reload - the audience condition evaluates immediately on the next request. For header-based attributes like geo, mock the header in middleware during local development, or use a VPN/proxy.
5

Validate on the Experimentation page

Once your audience matches, the variation key will appear in your live flag decisions on the FX demo page - confirming the attribute is flowing correctly through to FX and the Graph variation filter. View your session →

Personalization With FX, No Experiment #

Everything on this page describes an experiment, but none of the machinery requires one. A rollout with an audience condition and a single variation is personalization: middleware still decides at the edge, still appends __v_flag--variation, and Graph still filters. What changes is that there is no traffic split, so there is no lift to report - and includeOriginal: true is what keeps everyone outside the audience on base content.

This is the path to reach for when the audience is knowable from the request - device, locale, a persona cookie, auth state, a query parameter. When it depends on behaviour across sessions, the audience has to come from somewhere with history, which is what ODP segments as FX audiences are for, or skip the decision engine entirely and let ODP drive Graph directly.

The one thing personalization still owes you is a cache key. A rollout that varies content must set cms_flag and a route-scoped cms_route exactly like an experiment does, or middleware either skips it or fragments every page on the site. See the next section.

Organizing CMS Flags: cms_flag vs a Separate Project #

The middleware has to know which flags drive CMS content (and should rewrite the URL) versus component or client-side experiments that should not. There are two ways to draw that line - this demo uses the first, but the second is the cleaner setup at scale.

A marker variable in one project

this demo

Every flag lives in a single FX project. Each CMS experiment sets a cms_flag: true variation variable, and the middleware filters decideAll() down to those. Quickest to start.

+One project, one datafile, one SDK key
-The edge downloads and evaluates every flag, CMS or not
-Easy to forget cms_flag, or set it on the wrong experiment

A dedicated FX project

recommended

CMS-routing experiments live in their own FX project with their own datafile. Project membership is the marker - cms_flag disappears and the middleware evaluates only CMS flags. cms_route still scopes each experiment to a page.

+Smaller, faster edge datafile - only CMS flags
+A component experiment can never leak into CMS routing
+Independent TTL, ownership, and blast radius
-Two SDK keys; the client decision event must target this project too
If you split projects, the decision event must fire against the same project. Bucketing happens at the edge in the CMS project, but the decision event (step 7) fires in the browser via DecisionEvent. Point that client at the CMS project's public SDK key, or the decision event lands in the wrong project and the experiment shows no participants. Everything else - the optimizelyEndUserId cookie, cms_route scoping, includeOriginal, and ISR-per-variation - is unchanged.

Code: Middleware - decide + route-scoped URL rewrite #

Runs at the edge on every request. Sets the stable visitor ID, fetches the FX datafile (60s edge cache), evaluates all flags, keeps only the ones whose cms_flag and cms_route target this page, and rewrites the URL with variation path segments. The user's browser always sees the original URL - the rewrite is transparent.

Deciding at the edge
// Edge middleware
import { createInstance, createStaticProjectConfigManager, OptimizelyDecideOption }
  from "@optimizely/optimizely-sdk/universal";

export const VARIATION_MARKER = "__v_";   // arbitrary - pick anything URL-safe
const MAX_CMS_VARIATIONS = 3; // safety backstop; cms_route is the real control

// An experiment declares its target route via the cms_route variation variable.
// "" / undefined = all routes, "/*" = all, "/products/*" = prefix, "/x" = exact.
function routeMatches(pathname, cmsRoute) {
  if (!cmsRoute?.trim()) return true;
  const path = pathname.replace(/\/$/, "") || "/";
  return cmsRoute.split(",").some((raw) => {
    const entry = raw.trim();
    if (entry === "/*") return true;
    if (entry.endsWith("/*")) {
      const prefix = entry.slice(0, -2).replace(/\/$/, "") || "/";
      return path === prefix || path.startsWith(prefix + "/");
    }
    return path === (entry.replace(/\/$/, "") || "/");
  });
}

export async function middleware(request: NextRequest) {
  const response = NextResponse.next();

  // Set a stable visitor ID (not httpOnly - browser SDK reads it for decision events).
  const userId = request.cookies.get("optimizelyEndUserId")?.value ?? crypto.randomUUID();
  if (!request.cookies.get("optimizelyEndUserId")) {
    response.cookies.set("optimizelyEndUserId", userId, {
      maxAge: 60 * 60 * 24 * 365, sameSite: "lax", path: "/",
    });
  }

  // Skip API routes and already-rewritten paths.
  if (request.nextUrl.pathname.startsWith("/api/")) return response;
  if (request.nextUrl.pathname.includes(VARIATION_MARKER)) return response;

  // Fetch datafile with 60s edge cache (Vercel CDN caches this automatically).
  const datafile = await fetch(DATAFILE_URL, { next: { revalidate: 60 } }).then((r) => r.text());

  const client = createInstance({
    projectConfigManager: createStaticProjectConfigManager({ datafile }),
    requestHandler: noOpRequestHandler, // no HTTP needed - static config only
  });

  const ctx = client.createUserContext(userId, { device, hostname, logged_in, persona });
  const decisions = ctx.decideAll([OptimizelyDecideOption.DISABLE_DECISION_EVENT]);

  // Keep only CMS-content experiments (cms_flag) whose cms_route targets THIS path.
  // A visitor may match many experiments across the site; only the one authored on
  // the current page should be applied here - this is what keeps the cache clean.
  // Sorted by variationKey for a stable ISR cache key, then capped as a backstop.
  const activeDecisions = Object.values(decisions)
    .filter((d) => d.enabled && d.variationKey && d.variationKey !== "off")
    .filter((d) => d.variables?.cms_flag === true)
    .filter((d) => routeMatches(request.nextUrl.pathname, d.variables?.cms_route))
    .sort((a, b) => (a.variationKey as string).localeCompare(b.variationKey as string))
    .slice(0, MAX_CMS_VARIATIONS);

  if (activeDecisions.length === 0) return response;

  // Rewrite URL: /pricing → /pricing/__v_checkout_layout--single_step
  // Each segment encodes flagKey--variationKey so the page knows which flag fired.
  // The user sees /savings in the browser - the rewrite is transparent.
  // Next.js catches each rewritten path as a separate ISR cache entry.
  const url = request.nextUrl.clone();
  const variationSuffix = activeDecisions
    .map((d) => `__v_${d.flagKey}--${d.variationKey}`)
    .join("/");
  url.pathname = url.pathname.replace(/\/$/, "") + `/${variationSuffix}`;
  return NextResponse.rewrite(url, { headers: response.headers });
}

Code: CMS page route - variation filter #

The catch-all route reads the variation keys out of the URL, collects them, and passes them to Graph. Every CMS page automatically serves the right content variant.

Reading the variation back in the content route
// The catch-all content route
// Middleware rewrites: /pricing → /pricing/__v_checkout_layout--single_step
// Both flagKey and variationKey are encoded in the URL segment so the page
// knows which flag to fire the decision event for - no extra SDK call needed.
// Keep parsing in one module, shared with middleware:
// a malformed segment parses to null instead of an undefined variation key.
import { isVariationSegment, parseVariationSegment } from "@/lib/optimizely/variationPath";

function extractVariations(slug) {
  const cleanSlug = slug?.filter((s) => !isVariationSegment(s));
  const flagVariations = slug
    ?.map(parseVariationSegment)
    .filter((fv) => fv !== null) ?? [];
  return {
    cleanSlug,
    activeVariations: flagVariations.map((fv) => fv.variationKey), // for Graph filter
    flagVariations,                                                  // for decision event
  };
}

async function CmsPage({ params }) {
  const { slug } = await params;
  const { cleanSlug, activeVariations, flagVariations } = extractVariations(slug);

  // Pass variation keys to Graph - same filter as always, now sourced from URL not cookies.
  // stored: false opts out of Graph stored queries, which would otherwise freeze the
  // variation value from the first request and serve it to every visitor.
  const variationOption = activeVariations.length > 0
    ? {
        variation: { include: "SOME" as const, value: activeVariations, includeOriginal: true },
        stored: false,
      }
    : undefined;

  const client = getClient();
  const items = await client.getContentByPath(`/${cleanSlug.join("/")}/`, variationOption);
  const page = items.find((i) => activeVariations.includes(i._metadata?.variation)) ?? items[0];

  // Look up flagKey by the variationKey Graph actually served
  const servedVariation = page._metadata?.variation ?? null;
  const servedFlagKey = flagVariations.find((fv) => fv.variationKey === servedVariation)?.flagKey ?? null;

  return (
    <>
      <OptimizelyComponent content={page} />
      {servedFlagKey && <DecisionEvent flagKey={servedFlagKey} />}
    </>
  );
}

Code: Generated Graph query #

What getContentByPath sends to Graph under the hood when a variation is active - the base and the named variation come back together, and includeOriginal: true guarantees a safe fallback.

Theoretical GraphQL (personal variation active)
# Theoretical GraphQL generated by getContentByPath("/en/", variationFilter)
# when the user is bucketed into the "personal" variation:

query {
  _Content(
    where: {
      _metadata: { url: { default: { eq: "/en/" } } }
    }
    variation: {
      include: SOME
      value: ["personal"]
      includeOriginal: true   # always include the base - safe fallback for non-experiment users
    }
    limit: 10
  ) {
    items {
      _metadata {
        key
        version
        variation       # "personal" | null (base version)
        url { default }
      }
      ... on HomePage {
        composition { ... }
      }
    }
  }
}

# Graph returns TWO items:
#   items[0] → base homepage          (variation: null)
#   items[1] → "personal" variation   (variation: "personal")
#
# The code picks items[1] because it matches activeVariations.
# If no matching CMS variation exists, includeOriginal: true ensures
# items[0] (the base) is returned - experiment is safe to deploy before
# editors create any CMS variations.

Code: Decision event (client) #

Middleware encoded flagKey--variationKey into the URL, so a shared parser already knows the flagKey - no extra SDK call. When Graph confirms a variation was served, the page mounts <DecisionEvent />, which calls decide(flagKey, []) client-side for that flag only. Its attributes must mirror the middleware context that produced the variation.

Firing the decision event from the client
// The component that fires the decision event, client-side
// flagKey is passed in from the page - it was encoded in the URL by middleware:
//   /pricing → /pricing/__v_checkout_layout--single_step
// The content route parses it back out.
// No decideAll() here - the flagKey is already known from the route.

"use client";
export function DecisionEvent({ flagKey }: { flagKey: string }) {
  useEffect(() => {
    const userId = getCookie("optimizelyEndUserId");
    if (!userId) return;
    void getOptimizelyBrowserClient().then((client) => {
      if (!client) return;
      const ua = navigator.userAgent;
      const device = /mobile|android|iphone|ipad/i.test(ua) ? "mobile" : "desktop";
      const persona = getCookie("persona");
      // Attributes MUST mirror what middleware used (it produced the served
      // variation) - including logged_in - or the decision event can land on a
      // different variation than the one that was rendered.
      const ctx = client.createUserContext(userId, {
        device,
        hostname: window.location.hostname,
        logged_in: !!getCookie("bucketing_id"),
        ...(persona ? { persona } : {}),
      });
      ctx?.decide(flagKey, []); // fire decision event for this flag only
    });
  }, [flagKey]);
  return null;
}

// The catch-all content route
// flagKey comes from the URL segment, not from a client-side SDK call.
// flagVariations = [{ flagKey: "homepage", variationKey: "business" }]
const { cleanSlug, activeVariations, flagVariations } = extractVariations(slug);
const servedVariation = page._metadata?.variation ?? null;
const servedFlagKey = flagVariations.find((fv) => fv.variationKey === servedVariation)?.flagKey ?? null;

return (
  <>
    <OptimizelyComponent content={page} />
    {servedFlagKey && <DecisionEvent flagKey={servedFlagKey} />}
  </>
);

Choosing an Approach #

There are three ways to integrate Feature Experimentation with a Next.js CMS page route. The right choice depends on whether CDN caching matters for your traffic profile.

A - This demoISR

Edge Middleware + URL rewrite

Middleware evaluates FX and rewrites the URL with a flagKey--variationKey pair. The page reads variation from params (no cookies()) so ISR works. Each variation URL is a separate CDN cache entry. A small client component fires the decision event after render.

+~10-50ms TTFB on cached requests
+CDN serves warm requests - server load scales down
-Decision event fires after page load (CSR)
-More moving parts (middleware + browser SDK)
BSSR

force-dynamic SSR

The SDK user context is created in the page render, reading cookies for userId and attributes. decideAll() runs server-side, variation filter goes to Graph, and the decision event fires server-side too. Simpler code - no middleware changes or client component needed.

+Simplest - everything in one server component
+Decision event fires synchronously before HTML
-~300-1000ms TTFB on every request (no caching)
-cookies() blocks ISR - force-dynamic is required
C - Not recommendedCSR

Client-side only

Page renders base content from the server. After hydration, the browser SDK evaluates flags and fetches the variation. Content swaps in after the initial render - the user sees a flash of the base content before the variation appears.

+No server changes required
-Visible flicker - base content shows for ~100-500ms
-Variation data in the waterfall - slower effective LCP
-CMS content fetched twice (base SSR + variation CSR)

Code: Single flag decision in a component #

For feature-gating or variable-driven UI outside the CMS page route. The same decision-event rule applies - call userCtx.decide(flagKey) (no options, or empty array) when the variation is actually rendered to fire the decision event.

Deciding a flag in a server component
// Server component - client and userId are resolved upstream
// (same pattern as middleware: createInstance with a 60s-cached datafile).

const userCtx = client.createUserContext(userId, attributes);

// Evaluate without firing a decision event:
const decision = userCtx.decide(
  "checkout_layout",
  [OptimizelyDecideOption.DISABLE_DECISION_EVENT],
);
if (!decision.enabled) return null;

// Variation will be rendered - fire the decision event:
void userCtx.decide("checkout_layout");

// Variables come back typed - cast to the type you expect:
const headline = decision.variables.headline as string;
const subheadline = decision.variables.subheadline as string;
return <Hero headline={headline} subheadline={subheadline} variation={decision.variationKey} />;

Bucketing ID Override #

By default the FX SDK buckets users by their userId. Setting the reserved $opt_bucketing_id attribute overrides which ID drives bucketing - while keeping the original userId for analytics. This means all users sharing the same bucketing ID land in the same variation, regardless of their individual user IDs.

B2B / account-level experiments

Every seat on the same company account sees the same variation. Avoids the awkward situation where user A sees Variation 1 and user B on the same account sees Variation 2 in the same meeting.

Cross-device consistency

A logged-in user ID works as the bucketing ID - the same variation follows the user across their phone, tablet, and desktop, regardless of which device generated their anonymous visitor ID.

Gradual rollouts to accounts

Roll out a new feature to 10% of accounts (not 10% of users). Avoids fragmenting the experience inside the same company during a staged rollout.

Overriding the bucketing id
// Normal decision - bucketed by the visitor's anonymous userId
const userCtx = client.createUserContext(userId, attributes);
const decision = userCtx.decide("checkout_layout", [DISABLE_DECISION_EVENT]);

// Account-level decision - when logged in, bucket by account ID instead.
// All seats on the same account see the same variation.
// userId is still used for analytics; only bucketing is overridden.
const accountCtx = accountId
  ? client.createUserContext(userId, {
      ...attributes,
      $opt_bucketing_id: accountId,
    })
  : null;
const accountDecision = accountCtx?.decide("checkout_layout", [DISABLE_DECISION_EVENT]);

Live comparison - checkout_layout

Sign in via the Audience Switcher (bottom-right) to see the account-level decision alongside your normal decision.

Normal - bucketed by userId

checkout_layout

Flag is off - enable it in the FX dashboard to see the live hero copy variation.

Account - bucketed by not set

Sign in via the Audience Switcher to activate

Your Session #

Live output from this reference implementation, not guidance - the attributes below were resolved for your actual request, and are what gets passed to Feature Experimentation as your audience attribute map on every page load. No round trip; it is evaluated entirely in-process. The useful thing to take from it is the shape: a small, stable set of attributes resolved once per request and reused by every decision.

Current Attributes

User ID6c9c40a0…23c0
devicedesktop
logged_infalse

No persona set - use the audience switcher to add one.

Audience Condition Preview

How FX evaluates common audience conditions against your current attributes. Use the switcher to see these update in real time.

persona = "personal"
not setno match
persona = "business"
not setno match
persona = "mortgages"
not setno match
persona = "investments"
not setno match
logged_in = true
falseno match
device = "mobile"
desktopno match
device = "desktop"
desktopmatches
FX

See your live flag decisions on the Experimentation page

The Experimentation demo shows which flags are enabled for your session, the variation keys being passed to Graph, and the exact CMS content filter applied on every page request.

View your session on the FX demo →

All Flag Decisions (diagnostics) #

A stable optimizelyEndUserId cookie is set by Next.js middleware on first visit, and every flag is evaluated for it via userContext.decideAll() - reload and you always land in the same variation. Changes in the FX dashboard take effect within 60 seconds (datafile cache TTL). Each decision carries a variables map of typed values (strings, booleans, numbers, JSON) that drive copy or configuration per variation without code changes.

banneroff
variation: off
{
  "title": "test",
  "description": "",
  "image": "",
  "imageText": "",
  "linkText": "",
  "cache": true,
  "banner_position": 0
}
search_algorithmenabled
variation: semantic
{
  "search_algorithm": {
    "_ranking": "SEMANTIC",
    "_semanticWeight": 0.9
  }
}
homepageoff
variation: off
{
  "cms_flag": true
}
hero_layoutoff
variation: off
{
  "layout": "split"
}
nav_search_styleoff
variation: off
{
  "style": "icon"
}
product_card_orderoff
variation: off
{
  "order": "checking_first"
}
trust_section_styleoff
variation: off
{
  "style": "stats"
}
footer_ctaoff
variation: off
{
  "style": "app_download"
}
hero_dual_ctaoff
variation: off
{
  "secondaryLabel": "",
  "secondaryUrl": ""
}
hero_social_proofoff
variation: off
mobile_navoff
variation: off
rates_baroff
variation: off
{
  "apy": "4.75%",
  "product": "savings"
}
sticky_offer_baroff
variation: off
{
  "message": "Limited offer: 0% APR for 12 months on balance transfers.",
  "linkText": "Learn more",
  "linkUrl": "/personal/credit-cards",
  "expiryLabel": "Ends Friday"
}
hero_copyoff
variation: off
{
  "headline": "",
  "subheadline": ""
}
example_turnoff_onoff
variation: off
savings_offerenabled
variation: rate_led
{
  "cms_flag": true,
  "cms_route": "/savings-offer"
}

Key Things to Know#

  • →The variation key is the only contract between FX and the CMS. The string must match exactly (case-sensitive) between the FX variation key and the CMS variation name.
  • →includeOriginal: true means users outside the experiment always get the original content. Safe to add the filter before any CMS variations exist.
  • →Datafile is cached for 60 seconds via Next.js fetch revalidation. Changes in the FX dashboard propagate within one minute with no server restart.
  • →React cache() is scoped to a single HTTP request. Wrapping the user context factory in React cache() means any number of server components can call it and share one context per request. Concurrent visitors each get their own completely isolated context; nothing is shared across users.
  • →DISABLE_DECISION_EVENT suppresses decision events during the middleware routing pass. Once the variation is rendered, <DecisionEvent flagKey={...} /> mounts client-side and fires the decision event for that flag only - its attributes must mirror the middleware context, or the decision event can bucket differently than what was served.
  • →cms_route scopes an experiment to a page. The middleware only applies a variation whose cms_route matches the current path, so many CMS experiments can run across the site without colliding or fragmenting each other's ISR cache. There is no server-side re-decide - page._metadata.variation confirms what Graph served.
  • →Variations work on any content type - pages, shared blocks, navigation. Wherever Graph accepts a variation filter, the SDK wires in seamlessly.
  • →CMS variations must be created in Visual Builder, but can then be updated via the Management API. The REST API silently ignores the variation field on POST - you cannot create a named variation programmatically. But creating one in the UI generates a new draft version that you can PATCH and publish.
How this reference implementation does it3 files
src/lib/optimizely/user.ts
import { cache } from "react";
import { OptimizelyDecideOption } from "@optimizely/optimizely-sdk";
import { getOptimizelyClient } from "./experimentation";
import type { FxDecision, FxAttributes } from "./experimentation";
import { getVisitorContext } from "./visitor";

type DecideOpts =
  | OptimizelyDecideOption[]
  | { options?: OptimizelyDecideOption[]; bucketingId?: string; attributes?: FxAttributes };

function resolveOpts(opts: DecideOpts | undefined): {
  sdkOptions: OptimizelyDecideOption[];
  bucketingId?: string;
  attributes?: FxAttributes;
} {
  if (!opts || Array.isArray(opts)) {
    return { sdkOptions: opts ?? [OptimizelyDecideOption.DISABLE_DECISION_EVENT] };
  }
  return {
    sdkOptions: opts.options ?? [OptimizelyDecideOption.DISABLE_DECISION_EVENT],
    bucketingId: opts.bucketingId,
    attributes: opts.attributes,
  };
}

const noDecision = (flagKey: string): FxDecision => ({
  flagKey, enabled: false, variationKey: null, variables: {}, reasons: [],
});

const noOpUser = {
  userId: "anonymous" as string,
  bucketingId: undefined as string | undefined,
  decide: (flagKey: string): FxDecision => noDecision(flagKey),
  decideAll: (): Record<string, FxDecision> => ({}),
};

export const getOptimizelyUser = cache(async () => {
  const [client, { userId, attributes, bucketingId }] = await Promise.all([
    getOptimizelyClient(),
    getVisitorContext(),
  ]);

  if (!client) return { ...noOpUser, userId, bucketingId };

  const ctx = client.createUserContext(userId, attributes);
  if (!ctx) return { ...noOpUser, userId, bucketingId };

  return {
    userId,
    bucketingId,
    decide(flagKey: string, opts?: DecideOpts): FxDecision {
      const { sdkOptions, bucketingId: bId, attributes: attrOverrides } = resolveOpts(opts);
      const activeCtx = bId || attrOverrides
        ? client.createUserContext(userId, {
            ...attributes,
            ...attrOverrides,
            ...(bId ? { $opt_bucketing_id: bId } : {}),
          }) ?? ctx
        : ctx;
      const d = activeCtx.decide(flagKey, sdkOptions);
      return {
        flagKey,
        enabled: d.enabled,
        variationKey: d.variationKey,
        variables: d.variables as Record<string, unknown>,
        reasons: d.reasons,
      };
    },
    decideAll(): Record<string, FxDecision> {
      const raw = ctx.decideAll([OptimizelyDecideOption.DISABLE_DECISION_EVENT]);
      const out: Record<string, FxDecision> = {};
      for (const [key, d] of Object.entries(raw)) {
        out[key] = {
          flagKey: key,
          enabled: d.enabled,
          variationKey: d.variationKey,
          variables: d.variables as Record<string, unknown>,
          reasons: d.reasons,
        };
      }
      return out;
    },
  };
});
src/lib/optimizely/visitor.ts
import { cache } from "react";
import { cookies, headers } from "next/headers";
import {
  buildFxAttributes,
  requestHost,
  DEMO_BUCKETING_ID_COOKIE,
  VISITOR_ID_COOKIE,
  type FxAttributes,
} from "./fxAttributes";

export type { FxAttributes };

export const getVisitorContext = cache(async (): Promise<{
  userId: string;
  attributes: FxAttributes;
  bucketingId?: string;
}> => {
  const [cookieStore, headerStore] = await Promise.all([cookies(), headers()]);
  const userId = cookieStore.get(VISITOR_ID_COOKIE)?.value ?? "anonymous";
  const bucketingId = cookieStore.get(DEMO_BUCKETING_ID_COOKIE)?.value;
  return {
    userId,
    attributes: buildFxAttributes({
      userAgent: headerStore.get("user-agent") ?? "",
      host: requestHost(headerStore),
      cookie: (name) => cookieStore.get(name)?.value,
    }),
    ...(bucketingId ? { bucketingId } : {}),
  };
});
src/lib/optimizely/profile.ts
import "server-only";
import { cache } from "react";
import { cookies } from "next/headers";
import type { Persona } from "@/lib/segment";
import { DEMO_PERSONA_COOKIE, READ_CATEGORIES_COOKIE } from "./cookieNames";
import { queryOdpSegments, resolveVariationKey } from "./odp";
import { emptyProfile, type VisitorProfile } from "./profileTypes";
import { getVisitorContext } from "./visitor";

// One visitor, as every Optimizely product sees them. Composes the existing
// readers rather than replacing any of them: getVisitorContext() for identity and
// FX attributes, the demo_persona cookie, the read-categories cookie, and odp.ts
// for audience membership.
//
// CACHING RULE, and it is the one thing to get right here:
// these functions call cookies(). Per CLAUDE.md, cookies() inside a "use cache"
// function throws, and reading it makes the route segment dynamic. Today exactly
// one page route is dynamic - "/" - via the noStore() branch in the catch-all.
// NEVER call these from anything rendered on an ISR route, or that route silently
// stops being cached. Permitted call sites:
//   - route handlers declaring `export const dynamic = "force-dynamic"`
//   - the existing homepage branch in src/app/[[...slug]]/page.tsx
//   - /demo/* pages, which are already excluded from bucketing and caching concerns
//   - client components, via GET /api/profile
// A block that wants the profile on a CMS page must fetch /api/profile from the
// browser instead. That is why RecommendationBlock renders a server shell plus a
// client child.

const PERSONAS = new Set<Persona>([
  "personal",
  "business",
  "mortgages",
  "investments",
  "new_visitor",
]);

function toPersona(value: string | undefined): Persona {
  return value && PERSONAS.has(value as Persona) ? (value as Persona) : "new_visitor";
}

// Term keys only, so a malformed or oversized cookie cannot reach a Graph filter.
// Cap mirrors the writer in lib/personalization/readCategories.ts.
function parseReadCategories(value: string | undefined): string[] {
  if (!value) return [];
  return value
    .split(",")
    .map((key) => key.trim())
    .filter((key) => /^[a-z0-9_]{1,64}$/.test(key))
    .slice(0, 3);
}

/** Cookie-only profile. No network calls, so it is safe to call freely. */
export const getVisitorProfile = cache(async (): Promise<VisitorProfile> => {
  const [context, cookieStore] = await Promise.all([getVisitorContext(), cookies()]);
  const { userId, attributes, bucketingId } = context;

  return {
    ...emptyProfile(),
    visitorId: userId,
    ...(bucketingId ? { bucketingId } : {}),
    attributes,
    persona: toPersona(cookieStore.get(DEMO_PERSONA_COOKIE)?.value),
    loggedIn: attributes.logged_in === true,
    device: attributes.device === "mobile" ? "mobile" : "desktop",
    readCategories: parseReadCategories(cookieStore.get(READ_CATEGORIES_COOKIE)?.value),
    resolved: "cookies",
  };
});

/**
 * Cookie profile plus live ODP audience membership. One ODP call, already fetch-cached
 * at 5 minutes in odp.ts, and the React cache() collapses repeat callers in a request.
 * `fresh` bypasses that cache for the verification surfaces that must show current state.
 */
export const getVisitorProfileWithSegments = cache(
  async (fresh = false): Promise<VisitorProfile> => {
    const base = await getVisitorProfile();
    if (base.visitorId === "anonymous") return base;

    const odpSegments = await queryOdpSegments(base.visitorId, fresh);
    return {
      ...base,
      odpSegments,
      odpVariation: resolveVariationKey(odpSegments) ?? null,
      resolved: "cookies+odp",
    };
  },
);