Developer Demo
Experimentation
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 demoEdge 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.
Web Experimentation & Personalization - client-side
not covered hereBest 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.
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.
Create the CMS variation
In Visual Builder, add a variation and name it to match the FX variation key.
Create the flag + rule
In FX, create the flag and a delivery rule. Set variation variables cms_flag: true and cms_route.
Create + attach audience
Define an audience from SDK attributes or ODP segments and attach it to the delivery rule.
Set traffic + enable
Allocate traffic and turn the rule on. FX now buckets visitors.
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_stepat the edge, before render→ codeFetch 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→ codeRecord 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→ codeCMS 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
Graph filter value
variation: { include: SOME, value: [ … ] }
businessCMS variation name
Visual Builder serves the variant named
businessor 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.
# 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
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, CTAThe 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.
// 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] } },
},
});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 variationsMiddleware 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_ledNo 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' }// 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
savings_offer, decided for your visitor id| CMS variation (page) | Variation Container (block) | |
|---|---|---|
| Scope | The whole page - one composition per variation | One region; the rest of the page is shared |
| Authored in | Visual Builder → Add variation, a full copy of the page each | Inside the page, one Variation block per key |
| Created through the API | No - Visual Builder only | Yes - ordinary content, so it can be seeded |
| Graph query | Variation filter: include SOME, includeOriginal, stored: false | The plain page query - every variation inline |
| Picks the variation | Graph | The app - on the server or in the browser |
| Caching | One ISR entry per variation | Edge: one per variation · Browser: one for all |
| Flicker | None | Edge: none · Browser: the fallback paints first |
| Payload | The served variation only | Edge: the served variation · Browser: every variation |
| Decision event | Fired by the catch-all route | Fired by the block itself |
Create the flag and its variations in Feature Experimentation
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.Add a Variation Container to the page
Add one Variation per variation key
Publish and start the rule
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
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) }))}
/>
);
}
"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>
);
}
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.
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.
Create a flag and targeting rules in Feature Experimentation
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.Create variations in Visual Builder, then set their compositions
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).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".Validate with the Audience Switcher
Enable the flag and start the experiment
Validate the experiment in your own app
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 demoAttributes 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
deviceread from User-Agent header server-side (no cookie - GDPR safe)
desktoplogged_infrom logged_in cookie (Audience Switcher)
falseAdding 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-nativeAttributes 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.
ODP segments
behavioral layerODP 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.
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.
Device & User-Agent
already liveThe 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"Persona
already liveThe 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"Auth session
already liveToggle 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"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"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"
}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.
Add the signal where visitor context is resolved
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().Register the attribute in the FX dashboard
Build an audience using the new attribute
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).Test locally with the attribute set
Validate on the Experimentation page
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.
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 demoEvery 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.
A dedicated FX project
recommendedCMS-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.
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.
// 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.
// 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 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.
// 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.
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.
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.
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.
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.
// 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.
// 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
6c9c40a0…23c0desktopfalseNo 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 matchpersona = "business"not setno matchpersona = "mortgages"not setno matchpersona = "investments"not setno matchlogged_in = truefalseno matchdevice = "mobile"desktopno matchdevice = "desktop"desktopmatchesSee 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{
"title": "test",
"description": "",
"image": "",
"imageText": "",
"linkText": "",
"cache": true,
"banner_position": 0
}search_algorithmenabled{
"search_algorithm": {
"_ranking": "SEMANTIC",
"_semanticWeight": 0.9
}
}homepageoff{
"cms_flag": true
}hero_layoutoff{
"layout": "split"
}nav_search_styleoff{
"style": "icon"
}product_card_orderoff{
"order": "checking_first"
}trust_section_styleoff{
"style": "stats"
}footer_ctaoff{
"style": "app_download"
}hero_dual_ctaoff{
"secondaryLabel": "",
"secondaryUrl": ""
}hero_social_proofoffmobile_navoffrates_baroff{
"apy": "4.75%",
"product": "savings"
}sticky_offer_baroff{
"message": "Limited offer: 0% APR for 12 months on balance transfers.",
"linkText": "Learn more",
"linkUrl": "/personal/credit-cards",
"expiryLabel": "Ends Friday"
}hero_copyoff{
"headline": "",
"subheadline": ""
}example_turnoff_onoffsavings_offerenabled{
"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_routematches 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.variationconfirms 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
variationfield onPOST- 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
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;
},
};
});
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 } : {}),
};
});
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",
};
},
);