Developer Demo

Experimentation

Optimizely Feature Experimentation runs alongside SaaS CMS on the same platform. Flag decisions are evaluated in edge middleware - the URL is rewritten with the variation key before the page renders, enabling full ISR caching per variation. Bucketing 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 integration

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, rewrites the URL with the variation key, and Graph returns the matching CMS content variant - so every variation is its own ISR cache entry. A small client component fires the bucketing event after the visitor sees it. The rest of this page details this path.

+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 (a cookie on the next request, or a client-side refetch with a flicker). Not covered in this demo - 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])
// → { homepage: { variationKey: 'business' } }
// rewrite: /savings → /savings/__v_homepage--business
src/middleware.ts→ 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: ['business'],
  includeOriginal: true,
}
src/app/[[...slug]]/page.tsx→ code
Client7

Record that the visitor saw it

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

decide('homepage')
src/components/FxBucketingEvent.tsx→ 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"],
    includeOriginal: true,
  },
}

Your live session right now

User IDanonymoumousstable per browser
Devicedesktopderived from User-Agent header (no cookie)
Bucketing ID-sign in via Audience Switcher to set
Active Variationssemantickeys passed to Graph (the filter above)

Live Demo: hero_copy #

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

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

hero_copy

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 demo_persona cookie by the Audience Switcher. 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 “Banking built for business” 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 __v_ 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, impression) 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 getVisitorContext() (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). Server-side, queryOdpSegments(userId) in src/lib/optimizely/odp.ts resolves the visitor's qualified segments; those feed the FX decision the same way attributes do. See Personalization → ODP for the full walkthrough.

Built-in Attributes

device

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

desktop
logged_in

from demo_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]);

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 impression must target this project too
If you split projects, the bucketing event must fire against the same project. Bucketing happens at the edge in the CMS project, but the impression (step 7) fires in the browser via FxBucketingEvent. Point that client at the CMS project's public SDK key, or the impression 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.

src/middleware.ts
// src/middleware.ts
import { createInstance, createStaticProjectConfigManager, OptimizelyDecideOption }
  from "@optimizely/optimizely-sdk/universal";

export const VARIATION_MARKER = "__v_";
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 bucketing 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: /savings → /savings/__v_homepage--business
  // 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.

src/app/[[...slug]]/page.tsx
// src/app/[[...slug]]/page.tsx
// Middleware rewrites: /savings → /savings/__v_homepage--business
//   VARIATION_MARKER = "__v_"   FLAG_VAR_SEP = "--"
// Both flagKey and variationKey are encoded in the URL segment so the page
// knows which flag to fire the bucketing event for - no extra SDK call needed.
import { VARIATION_MARKER, FLAG_VAR_SEP } from "@/middleware";

function extractVariations(slug) {
  const cleanSlug = slug?.filter((s) => !s.startsWith(VARIATION_MARKER));
  const flagVariations = slug
    ?.filter((s) => s.startsWith(VARIATION_MARKER))
    .map((s) => {
      const [flagKey, variationKey] = s.slice(VARIATION_MARKER.length).split(FLAG_VAR_SEP);
      return { flagKey, variationKey };
    }) ?? [];
  return {
    cleanSlug,
    activeVariations: flagVariations.map((fv) => fv.variationKey), // for Graph filter
    flagVariations,                                                  // for bucketing 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
  const variationOption = activeVariations.length > 0
    ? { variation: { include: "SOME" as const, value: activeVariations, includeOriginal: true } }
    : 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 && <FxBucketingEvent 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: Bucketing event (client) #

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

src/components/FxBucketingEvent.tsx
// src/components/FxBucketingEvent.tsx
// flagKey is passed in from the page - it was encoded in the URL by middleware:
//   /savings → /savings/__v_homepage--business
// extractVariations() in page.tsx parses it back out.
// No decideAll() here - the flagKey is already known from the route.

"use client";
export function FxBucketingEvent({ 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("demo_persona");
      // Attributes MUST mirror src/middleware.ts (which produced the served
      // variation) - including logged_in - or the impression 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("demo_bucketing_id"),
        ...(persona ? { persona } : {}),
      });
      ctx?.decide(flagKey, []); // fire bucketing event for this flag only
    });
  }, [flagKey]);
  return null;
}

// src/app/[[...slug]]/page.tsx
// 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 && <FxBucketingEvent 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 __v_flagKey--variationKey. 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 bucketing event after render.

+~10-50ms TTFB on cached requests
+CDN serves warm requests - server load scales down
-Bucketing 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 bucketing event fires server-side too. Simpler code - no middleware changes or client component needed.

+Simplest - everything in one server component
+Bucketing 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 impression rule applies - call userCtx.decide(flagKey) (no options, or empty array) when the variation is actually rendered to fire the impression.

src/components/SubscribeBanner.tsx
// 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 an impression:
const decision = userCtx.decide(
  "hero_copy",
  [OptimizelyDecideOption.DISABLE_DECISION_EVENT],
);
if (!decision.enabled) return null;

// Variation will be rendered - fire the impression:
void userCtx.decide("hero_copy");

// 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.

src/components/MyPage.tsx
// Normal decision - bucketed by the visitor's anonymous userId
const userCtx = client.createUserContext(userId, attributes);
const decision = userCtx.decide("hero_copy", [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("hero_copy", [DISABLE_DECISION_EVENT]);

Live comparison - hero_copy

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

Normal - bucketed by userId

hero_copy

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

variation: off

Account - bucketed by not set

Sign in via the Audience Switcher to activate

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

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 bucketing events during the middleware routing pass. Once the variation is rendered, <FxBucketingEvent flagKey={...} /> mounts client-side and fires the impression for that flag only - its attributes must mirror the middleware context, or the impression 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.
Source files1 file
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, _opts?: DecideOpts): 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;
    },
  };
});