Developer Demo
Feature Experimentation
Optimizely Feature Experimentation runs alongside SaaS CMS on the same platform. Flag decisions are evaluated server-side in React Server Components — no client JS, no layout shift. Variation keys from FX drive which CMS content variant Graph returns, connecting A/B experiments directly to editor-created content variations.
How It All Fits Together #
Four steps happen on every page request. The browser never touches the SDK or runs an experiment script — all decisions happen server-side before HTML is streamed.
Browser
Sends optimizelyEndUserId cookie
FX SDK (server)
Calls decide() with DISABLE_DECISION_EVENT — returns variationKey, no impression yet
Graph API
Filters by variation.value · serves matched CMS variant
Bucket the user
Fire decide() again — records user in experiment results
Middleware sets a stable ID
On first visit, Next.js middleware writes an optimizelyEndUserId UUID cookie (1 year TTL). It's the only cookie set — device is derived from the User-Agent header server-side (no cookie stored).
FX SDK evaluates flags server-side
getOptimizelyUser() reads the optimizelyEndUserId cookie — it has an expiration date, so the same visitor gets the same bucket across return visits. React cache() deduplicates within a single page load. The datafile is refreshed every 60 seconds.
Graph returns the right CMS variant
Active variation keys are passed to getContentByPath as a { include: 'SOME', value: [...keys] } filter. Graph serves the CMS content variant whose key matches — or the original if none exists.
Impression recorded in FX
After Graph confirms the CMS variation was served, a second decide(flagKey, []) fires without DISABLE_DECISION_EVENT. This sends the impression so the FX results page can track participants, measure lift, and declare a winner.
Your Session #
A stable optimizelyEndUserId cookie is set by Next.js middleware on first visit. Flag decisions below are bucketed to this ID — reload and you always land in the same variation.
All Flag Decisions
Evaluated via userContext.decideAll(). Changes in the FX dashboard take effect within 60 seconds (datafile cache TTL).
bannerenabled{
"title": "Variation #2",
"description": "This is the description.",
"image": "https://picsum.photos/800\n",
"imageText": "image description",
"linkText": "Continue reading...",
"cache": false,
"banner_position": 0
}subscribe_buttonenabled{
"subscribe_title": "Subscribe"
}product_sortenabled{
"sort_method": "best_sellers"
}search_algorithmenabled{
"search_algorithm": {
"_ranking": "SEMANTIC",
"_semanticWeight": 0.9
}
}city_book_trip_variationsoffadd_to_cartoff{
"button_color": "blue",
"button_style": "bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded"
}homepageoffmobile_appenabledLive Demo: subscribe_button #
This component is driven entirely by the subscribe_button flag and its subscribe_title variable.
variation: on
Subscribe
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.
Family plans & shared subscriptions
Households or linked accounts that share access should have a consistent experience — buck by the primary account ID, not by each individual member.
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.
import { getOptimizelyUser } from "@/lib/optimizely/user";
import { getVisitorContext } from "@/lib/optimizely/visitor";
export default async function MyPage() {
const user = await getOptimizelyUser();
const { bucketingId } = await getVisitorContext();
// Normal decision — bucketed by the visitor's stable userId
const decision = user.decide("subscribe_button");
// 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 accountDecision = bucketingId
? user.decide("subscribe_button", { bucketingId })
: null;
}Live comparison — subscribe_button
Sign in via the Audience Switcher (bottom-right) to see the account-level decision alongside your normal decision.
Normal — bucketed by userId
variation: on
Subscribe
variation: on
Account — bucketed by not set
Sign in via the Audience Switcher to activate
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 request time — no code change required after initial setup.
Request flow — this demo's homepage_audience experiment
FX Dashboard
flag: homepage_audience
variation: business
FX SDK
user bucketed into
variationKey: business
Graph query
variation: {
include: 'SOME',
value: ['business']
}
CMS serves
content variant
named "business"
(or original if no match)
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". See scripts/update-homepage-variations.ts.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: ["banner2","on","best_sellers","semantic","get_the_app"],
includeOriginal: true,
},
}Setting Up CMS Variations — 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.
Create a flag and experiment in Feature Experimentation
homepage_audience with two audience-targeted delivery rules and a fallback: personal (33%, audience: persona == "personal"), business (33%, audience: persona == "business"), and off (34%). The persona attribute is set from the demo_persona cookie by the Audience Switcher.Create variations in Visual Builder, then update via script
personal and business (case-sensitive — must match the FX variation keys exactly). Each becomes a new draft version in the CMS. Then run npx tsx scripts/update-homepage-variations.ts to PATCH those versions with the correct compositions and publish them — no manual composition editing in the UI needed.Validate with the Audience Switcher
Enable the flag and start the experiment
Validate with this page
Audience Targeting #
FX attributes (like device) are matched against audiences defined in the FX dashboard. Targeting happens on the server — the browser never knows which audience it was matched to.
Built-in Attributes
deviceread from User-Agent header server-side (no cookie — GDPR safe)
desktoplogged_infrom demo_logged_in cookie (Audience Switcher)
falseAdding Custom Attributes
Spread the base visitor attributes from getVisitorContext() and add your extras as the third argument to getDecision(). Then define matching audience conditions in the FX dashboard.
// Base attributes (device, logged_in, persona) are automatic.
// For extra attributes, spread after getVisitorContext():
const { userId, attributes } = await getVisitorContext();
await getDecision("my_flag", userId, {
...attributes,
plan: "premium", // from your database
country: "GB", // from geo header
});Feature Variables #
Each variation in a flag can carry typed variables — strings, booleans, numbers, or JSON. Variables let editors control copy, configuration, and styling without code changes. The subscribe_button flag uses a subscribe_title string variable to drive the CTA text.
What's available now
bannerbanner2{
"title": "Variation #2",
"description": "This is the description.",
"image": "https://picsum.photos/800\n",
"imageText": "image description",
"linkText": "Continue reading...",
"cache": false,
"banner_position": 0
}subscribe_buttonon{
"subscribe_title": "Subscribe"
}product_sortbest_sellers{
"sort_method": "best_sellers"
}search_algorithmsemantic{
"search_algorithm": {
"_ranking": "SEMANTIC",
"_semanticWeight": 0.9
}
}city_book_trip_variationsoffno variables defined
add_to_cartoff{
"button_color": "blue",
"button_style": "bg-blue-600 hover:bg-blue-700 text-white font-semibold py-2 px-4 rounded"
}homepageoffno variables defined
mobile_appget_the_appno variables defined
Reading variables in code
const user = await getOptimizelyUser();
const decision = user.decide("subscribe_button");
// decision.variables is Record<string, unknown>
// Cast to the type you expect
const title =
decision.variables.subscribe_title as string;
const config =
decision.variables.hero_config as {
headline: string;
cta: string;
theme: "light" | "dark";
};Integration Code #
1. Middleware — stable user ID + device detection
Sets first-party cookies on every request. Runs at the edge before any page logic.
// src/middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
export function middleware(req: NextRequest) {
const res = NextResponse.next();
// Only one cookie — a stable visitor ID for consistent bucketing.
// Device type is read from the User-Agent header server-side (no cookie = GDPR safe).
if (!req.cookies.get("optimizelyEndUserId")) {
res.cookies.set("optimizelyEndUserId", crypto.randomUUID(), {
maxAge: 60 * 60 * 24 * 365, // 1 year
httpOnly: true,
sameSite: "lax",
});
}
return res;
}2. FX user helper — one user context per request
The optimizelyEndUserId cookie has an expiration date, so the same visitor always lands in the same bucket — across page loads and return visits. React cache() deduplicates within a single render tree: cookies are read once, the SDK context is created once, and concurrent visitors each get their own independent context — nothing shared across users. decideAll() evaluates every flag for this visitor in one call with DISABLE_DECISION_EVENT — no impressions fired yet.
// src/lib/optimizely/user.ts
import { cache } from "react";
import { OptimizelyDecideOption } from "@optimizely/optimizely-sdk";
import { getOptimizelyClient } from "./experimentation";
import { getVisitorContext } from "./visitor";
// cache() scopes to a single HTTP request's render tree.
// Every component that calls getOptimizelyUser() shares one user context.
// 1,000 concurrent visitors → 1,000 independent contexts, nothing shared.
export const getOptimizelyUser = cache(async () => {
const [client, { userId, attributes, bucketingId }] = await Promise.all([
getOptimizelyClient(), // one SDK instance per request (React cache() + 60s Next.js fetch cache)
getVisitorContext(), // reads: optimizelyEndUserId cookie, User-Agent → device, demo cookies
]);
if (!client) return noOpUser; // FX unreachable → all decide() calls return { enabled: false }
const ctx = client.createUserContext(userId, attributes);
return {
userId,
bucketingId,
//
// decide(flagKey) → DISABLE_DECISION_EVENT: flag evaluated, no impression fired
// decide(flagKey, []) → no options → impression fires, registers user in FX results
// decide(flagKey, {bucketingId}) → override bucketing ID for account-level experiments
// decide(flagKey, {attributes}) → merge extra attributes for this call only
//
decide(flagKey: string, opts?: OptimizelyDecideOption[] | DecideOpts): FxDecision { /* ... */ },
decideAll(): Record<string, FxDecision> {
// Always DISABLE_DECISION_EVENT — evaluate all flags without recording anything.
return ctx.decideAll([OptimizelyDecideOption.DISABLE_DECISION_EVENT]);
},
};
});Why a wrapper instead of calling the SDK directly?
Optimizely ships two SDKs. @optimizely/react-sdk is a separate package that provides an OptimizelyProvider context wrapper and a useDecision() hook — designed for client-side React apps where a single SDK instance lives in the browser and flags are evaluated in the client. That SDK also has an isServerSide prop on OptimizelyProvider that disables background polling and event batching during SSR so the SDK doesn't try to run browser-only logic — it was deprecated in react-sdk v4 in favour of configuring a static per-request instance directly.
This project uses @optimizely/optimizely-sdk directly instead. Next.js App Router server components can't use React context or hooks, so OptimizelyProvider and useDecision don't apply. The wrapper getOptimizelyUser() fills the same role: it resolves the stable userId and visitor attributes from cookies and headers, creates one user context per request via React cache(), and defaults to DISABLE_DECISION_EVENT so routing passes don't pollute the FX results page — you fire the impression with user.decide(flagKey, []) only when the variation is actually rendered.
Two-layer architecture
getOptimizelyClient() — SDK instance, cached 60s by Next.js fetch. One datafile download per minute regardless of traffic.
getOptimizelyUser() — user context, scoped to the current HTTP request via React cache(). Isolated per visitor, never shared.
When to use what
user.decide(flagKey) — preferred. Full visitor context already resolved. Supports { bucketingId } and { attributes } overrides.
getDecision(flagKey, userId, attrs) — low-level, for standalone calls outside a component tree (e.g. middleware, API routes).
// ❌ Using the FX SDK directly — don't do this in server components
import { createInstance } from "@optimizely/optimizely-sdk";
export default async function MyPage() {
const res = await fetch(DATAFILE_URL); // fetched fresh every render
const client = createInstance({ datafile: await res.text() });
const ctx = client?.createUserContext(???); // where does userId come from?
const decision = ctx?.decide("my_flag"); // no impression control
}
// ✅ Using the wrapper — one function call, everything resolved
import { getOptimizelyUser } from "@/lib/optimizely/user";
export default async function MyPage() {
const user = await getOptimizelyUser();
// userId + device + persona attributes come from cookies automatically.
// SDK instance is memoised per request — datafile not re-fetched.
// DISABLE_DECISION_EVENT by default — impression not fired yet.
const decision = user.decide("my_flag");
if (!decision.enabled) return null;
// Now the variation will be rendered — fire the impression.
void user.decide("my_flag", []);
return <Variant variables={decision.variables} />;
}3. CMS page route — flags → Graph variation filter
The catch-all route evaluates all flags, collects active variation keys, and passes them to Graph. Every CMS page automatically serves the right content variant.
// src/app/[[...slug]]/page.tsx
import { getOptimizelyUser } from "@/lib/optimizely/user";
import { getClient } from "@optimizely/cms-sdk";
async function CmsPage({ params }) {
// Step 1 — evaluate all FX flags (userId + attributes from cookies automatically)
const user = await getOptimizelyUser();
const decisions = user.decideAll();
// Step 2 — collect active variation keys (skip "off")
const activeVariations = Object.values(decisions)
.filter((d) => d.enabled && d.variationKey && d.variationKey !== "off")
.map((d) => d.variationKey as string);
// Step 3 — pass them to Graph as a variation filter
// includeOriginal: true → users NOT in an experiment still see original content
const variationOption = activeVariations.length > 0
? {
variation: {
include: "SOME" as const,
value: activeVariations, // e.g. ["banner1", "contribute"]
includeOriginal: true,
},
}
: undefined;
// Step 4 — Graph returns the CMS variant whose key matches, or falls back
const client = getClient();
const [page] = await client.getContentByPath(`/en/${slug}/`, variationOption);
return <OptimizelyComponent content={page} />;
}What getContentByPath sends to Graph under the hood:
# 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.4. Fire the impression when the variation is rendered
user.decideAll() uses DISABLE_DECISION_EVENT — flags are evaluated without sending an impression. Once the variation is actually rendered, call user.decide(flagKey, []) with an empty options array to fire the impression. This is what registers the user on the experiment results page — without it, the results API sees zero participants even though content is being personalised.
// src/app/[[...slug]]/page.tsx
// After Graph returns the page, check whether it served a CMS variation.
// If it did, call decide() with an empty options array so FX records the user
// in experiment results. The first decideAll() was routing-only (DISABLE_DECISION_EVENT).
const servedVariation = page._metadata?.variation ?? null;
if (servedVariation) {
const exposedFlag = Object.values(decisions).find(
(d) => d.variationKey === servedVariation
);
if (exposedFlag) {
// Empty options → no DISABLE_DECISION_EVENT → impression fires to FX results
// void discards the return value — we only care about the side effect here
void user.decide(exposedFlag.flagKey, []);
}
}5. Using a single flag decision in a component
For feature-gating or variable-driven UI outside the CMS page route. The same impression rule applies — call user.decide(flagKey, []) when the variation is actually rendered to fire the impression.
import { getOptimizelyUser } from "@/lib/optimizely/user";
export default async function MyPage() {
const user = await getOptimizelyUser();
// Evaluate without firing an impression (default: DISABLE_DECISION_EVENT)
const decision = user.decide("subscribe_button");
if (!decision.enabled) return null;
// Once you know the variation will be shown, fire the impression:
void user.decide("subscribe_button", []);
// Variables come back typed — you cast to the type you expect
const title = decision.variables.subscribe_title as string;
return <Banner title={title} variation={decision.variationKey} />;
}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. Any number of server components can call
getOptimizelyUser()— they all share one user context for that request. Concurrent visitors each get their own completely isolated context; nothing is shared across users. - →DISABLE_DECISION_EVENT suppresses impression events during the routing pass. Call
user.decide(flagKey, [])only when the variation is actually rendered — the empty options array omitsDISABLE_DECISION_EVENT, firing the impression and recording the user in FX experiment results. - →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
variationfield onPOST— you cannot create a named variation programmatically. But creating one in the UI generates a new draft version; you can thenPATCH /content/{key}/versions/{version}to set its composition and publish it programmatically.
Source files1 file
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;
},
};
});