Developer Demo

Personalization & Audiences

Four paths to personalized CMS content - from the full Optimizely stack (FX + ODP) down to bringing your own audience logic entirely. All server-side paths feed the same Graph variation filter.
✓ FX: delivery engine + experiment results✓ ODP: audience layer, works with or without FXFX + ODP combination supported

Optimizely has several products that all touch personalization - FX, ODP, Web Experimentation, and the CMS each have their own audience system and their own way of showing different content to different visitors. That overlap is real and intentional, but it can make it hard to know which to reach for, or whether to combine them.

The clearest way to think about it is in layers, not alternatives: ODP is the data layer (who is this visitor, based on their history); FX is the decision layer (which variant should they see, and can we measure lift); Optimizely Graph is always the delivery layer (it serves the right CMS variant once it has a variation key). The four paths below are different ways of stacking those layers - from the full Optimizely stack down to bringing your own logic entirely.

Four Paths to Personalized Content #

  • Feature Experimentation (FX) - delivery engine: configures audiences, buckets traffic, runs A/B experiments, and produces statistical results. Managed in your source code, so the decision can run at the edge / server-side or client-side via an SDK.
  • ODP (Optimizely Data Platform) - audience layer only: builds behavioral profiles from cross-session events. No delivery role - plugs into FX as an audience source, or drives Graph directly.
  • Optimizely Graph - content delivery API: always the final step, regardless of which path resolves the variation key. Serves the right CMS variant based on the key it receives.
  • Web Experimentation / Personalization - a separate, standalone Optimizely product with its own visual editor and its own client-side delivery: it buckets visitors and applies changes in the browser, independent of the CMS and Graph. It can be bridged to CMS content as a last resort, but the server-side paths above are simpler and have no lag.
  • FX and ODP can be combined - ODP segments used as FX audience conditions. Path 3 is the escape hatch for bringing your own audience logic entirely.

Path 1 - Feature Experimentation (server-side, CMS-integrated)

Audience signal

device, persona, geo, auth

FX engine

delivery, experiments, results

Graph filter

getContentByPath()

CMS variant

or original fallback

FX is the delivery engine: it evaluates audience rules in-process, buckets traffic, and returns a variation key that Graph uses to serve the right CMS variant. The FX results page tracks impressions and conversions per variation and runs a statistical significance test - you can declare a winner with a measured confidence interval. FX audience conditions can be fed from native request-time attributes or ODP segments (the combination) - see FX Audience Sources below. Use this path when you need experiments, hold-out groups, or measurable lift.

Path 2 - ODP direct (server-side, no Feature Experimentation)

ODP segments API

queryOdpSegments(userId)

Segment map

segment name → variationKey

Graph filter

getContentByPath()

CMS variant

or original fallback

ODP is an audience layer only - it has no delivery role. Query it for the visitor's behavioral segments, map a segment name to a variation key, and pass it straight to Graph. CMS/Graph still does the content delivery, just as in Path 1 - only the decision layer differs. ODP reporting covers audience analytics (segment reach, engagement trends) - not a controlled statistical test. Use this path for pure personalization for known behavioral segments with no experiment to run.

Path 3 - Custom audience layer (server-side, direct to Graph)

Custom logic

cookie, DB, API, 3rd-party service

Variation key

resolved by your code

Graph filter

getContentByPath()

CMS variant

or original fallback

Graph's variation filter accepts any string as the variation key - it does not care where it came from. Plug in whatever audience system you already have: read a cookie your own system wrote, call an internal API, query a database, or derive the key from request headers. Resolve the key in your Server Component and pass it to getContentByPath(url, { variation: { include: "SOME", value: [key], includeOriginal: true } }). The rest of the delivery pipeline - Graph filter, CMS variant, original fallback - works identically to Paths 1 and 2.

Path 4 - Web Experimentation / Personalization (separate, standalone product)

Unlike Paths 1-3, this is not a server-side route to CMS content - it is a separate, standalone Optimizely product with its own visual editor and its own client-side delivery. It buckets visitors and applies changes in the browser, independent of the CMS and Graph, and tracks its own results. It can be bridged to serve CMS content, but only as a last resort - a cookie with a one-request lag, or a client-side refetch with a flicker; the server-side paths above are simpler and have no lag. See the fallback bridge below.

FX and ODP are not mutually exclusive. ODP segments can be used as FX audience conditions - the decision still runs through FX's delivery engine (bucketing, experiments, statistical results), but the audience condition references a behavioral segment rather than a request-time attribute. This is the combination: ODP depth with FX infrastructure. It is covered in the FX Audience Sources section below.
Coming from CMS12/13 Visitor Groups / Audiences? In Optimizely CMS12/13 (.NET), this feature was called Visitor Groups and later renamed to Audiences. Criteria are defined in the CMS admin UI and evaluated server-side in-process at request time via IsMatch(). Content blocks show or hide at the component level based on audience membership. No separate SDK - purely rules-based, built into the CMS, no A/B testing or statistical results. In the SaaS CMS (this demo), Visitor Groups / Audiences do not exist - the CMS is headless with no built-in rendering layer. Personalization is externalized: FX audience rules take the place of criteria for request-time facts (device, country, login state); ODP segments take the place of behavioral criteria (page view history, high-value, churn risk). FX also adds what Visitor Groups never had: experiments, traffic splits, and statistical results.

FX Audience Sources: Native Attributes vs ODP Segments #

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

Native FX attributes

default · app-native

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

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

ODP segments

behavioral layer

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

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

FX Native Attributes in Depth #

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

1

Device & User-Agent

already live

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

Your current device attribute: desktop

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

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

Persona

already live

The Audience Switcher sets a demo_persona cookie. getVisitorContext() 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("demo_persona")?.value;

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

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

Auth session

already live

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

Current value: false

import { getServerSession } from "next-auth";

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

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

Geo / Country (request headers)

Vercel, Cloudflare, and most edge runtimes inject geo headers on every request. Add them to getVisitorContext() and they become available as FX audience conditions instantly.

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

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

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

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

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

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

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

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

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

Combining attributes - audience conditions in FX

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

// All attributes are set once when creating the user context
const userCtx = client.createUserContext(userId, {
  // Base attributes (device, persona, logged_in) from getVisitorContext()
  ...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 getVisitorContext(), it becomes available as an FX audience condition with no further SDK configuration.

1

Add the signal to getVisitorContext()

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

Register the attribute in the FX dashboard

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

Build an audience using the new attribute

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

Test locally with the attribute set

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

Validate on the Experimentation page

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

ODP: The Behavioral Layer for Deeper Targeting #

ODP is a behavioral profile store - it remembers what a visitor did over time. You can use it in two ways: reference an ODP segment as an FX audience (decision still runs through FX, with experiments and statistical results), or skip FX entirely - query ODP directly, map a segment name to a CMS variation key, and pass it straight to Graph. For how ODP builds profiles (identity stitching, events, segments) and the full direct-path Server Component, see identity, events & segments below.

Your ODP Segments

Fetched server-side from ODP's GraphQL API using your optimizelyEndUserId as the visitor identifier.

No segments returned for this visitor.

Resolved variation key

none - original content served
src/lib/optimizely/odp.ts
// Decouples ODP segment names from CMS variation names.
// Update this map when either side renames something -
// no changes needed in FX dashboard or CMS UI.
export const ODP_SEGMENT_TO_VARIATION = {
  "known_customers": "returning",
  "business_banking_customer": "business",
  "personal_banking_customers": "personal",
  "business_visitors": "business",
  "mortgage_visitors": "mortgages",
};
When to use ODP direct vs FX. Use the ODP direct path when the goal is personalizing content for known segments - no control group, no statistical test. Use the FX path when you need to run a real experiment: split traffic, measure lift, and declare a winner with confidence. Both paths feed the same Graph variation filter - only the decision layer differs.

ODP: identity, events & segments #

The segments above don't appear by magic - ODP (Optimizely Data Platform) builds a behavioral profile per visitor from events the browser sends, then evaluates segment membership in real time. Three things flow through it: an event tag in the page head, identity stitching that links ODP's cookie to the FX visitor ID, and a server-side query that reads segment membership back out. Once you have a segment, the direct path maps it to a CMS variation and passes it straight to Graph - no FX engine required.

The ODP tag

The browser sends events (pageviews, identity) through the ODP tag. It is inlined in the root layout's head so the zaius command queue exists synchronously - events fired before the async script loads are queued and replayed.

The ODP tag (inlined in the root layout head)
// src/app/layout.tsx - the ODP tag is inlined in <head> so the zaius
// command queue exists synchronously during HTML parsing. Events fired
// before the async script loads are queued and replayed.

var zaius = window['zaius'] || (window['zaius'] = []);
zaius.methods = ['initialize','onload','customer','entity','event', /* ... */];
// ...queue shim...
e.src = 'https://d1igp3oop3iho5.cloudfront.net/v2/' +
        NEXT_PUBLIC_OPTIMIZELY_ODP_TRACKER_ID + '/zaius-min.js';

Identity stitching

ODP tracks browsers by its own vuid cookie, but everything else in this app keys off optimizelyEndUserId (set by middleware, used by Feature Experimentation). Linking the two once via the fs_user_id identifier is what lets the server ask ODP about the same visitor the FX SDK is bucketing.

src/components/OdpSetup.tsx
// src/components/OdpSetup.tsx ("use client", rendered in the root layout)
//
// ODP assigns every browser its own vuid cookie. To query segments
// server-side using the FX visitor ID, the two identities must be linked:
// send optimizelyEndUserId to ODP as the fs_user_id identifier once.

useEffect(() => {
  const fsUserId = getCookie("optimizelyEndUserId");
  if (fsUserId) window.zaius?.entity("customer", { fs_user_id: fsUserId });
}, []);

// SPA route changes don't reload the page, so fire a pageview per navigation:
useEffect(() => {
  window.zaius?.event("pageview");
}, [pathname]);

Server-side segment queries

ODP exposes a GraphQL API for profile data. The app asks one narrow question per request: of the segments this app cares about, which does the visitor qualify for? The subset filter keeps the query cheap, the 300s cache keeps it off the hot path, and any failure returns an empty array - personalization degrades to the default content, never to an error page.

src/lib/optimizely/odp.ts
// src/lib/optimizely/odp.ts - server-side segment membership query.
// Auth is the ODP API key in an x-api-key header (server-only env var).

const SEGMENT_QUERY = `
  query GetSegments($userId: String!, $segmentFilter: [String!]!) {
    customer(vuid: $userId) {
      audiences(subset: $segmentFilter) {
        edges { node { name state } }
      }
    }
  }
`;

export async function queryOdpSegments(userId: string): Promise<string[]> {
  const res = await fetch(`${ODP_API_HOST}/v3/graphql`, {
    method: "POST",
    headers: { "Content-Type": "application/json", "x-api-key": ODP_API_KEY },
    body: JSON.stringify({ query: SEGMENT_QUERY, variables: { userId, segmentFilter } }),
    next: { revalidate: 300 },   // segment membership changes slowly - cache it
  });
  // ...filter edges to state === "qualified", return segment names
}

Direct path: ODP segments to CMS variants

No FX engine in the loop. Query the visitor's segments, map one to a CMS variation key, pass that key straight to Graph - which returns the matching variant, or the original page if nothing matches. Use this when personalizing for known behavioral segments with no experiment to run; reach for the FX path when you need traffic splits, hold-out groups, or significance testing (it runs the identical Graph variation filter, just behind the FX decision engine).

ODP direct pipeline

queryOdpSegments()

subset filtered, 300s cached

resolveVariationKey()

first matching segment wins

Graph variation filter

getContentByPath({ variation })

CMS variant

or original fallback

Complete Server Component - copy and adapt
// Any Server Component - the complete ODP direct → Graph pipeline.
// No FX engine in the loop; just segments, a key, and a Graph filter.

import { getVisitorContext } from "@/lib/optimizely/visitor";
import { queryOdpSegments, resolveVariationKey } from "@/lib/optimizely/odp";
import { getClient } from "@optimizely/cms-sdk";

export default async function Page({ params }) {
  const { userId } = await getVisitorContext();

  // 1. Ask ODP which segments this visitor qualifies for.
  //    Only the segments in ODP_SEGMENT_TO_VARIATION are checked.
  //    Result is cached 300s - does not hit ODP on every page request.
  const segments = await queryOdpSegments(userId);

  // 2. Map the first qualifying segment to a CMS variation key.
  //    Returns undefined when no segment matches - original content served.
  const variationKey = resolveVariationKey(segments);

  // 3. Build the Graph variation filter and fetch the page.
  //    includeOriginal: true is required - without it, unmatched visitors
  //    get no content at all instead of the default page.
  const variationFilter = variationKey
    ? { variation: { include: "SOME" as const, value: [variationKey], includeOriginal: true } }
    : undefined;

  const url = "/" + ((await params).slug ?? []).join("/");
  const [page] = await getClient().getContentByPath(url, {
    ...variationFilter,
    next: { revalidate: 3600, tags: ["page"] },
  } as any);

  // Render page normally - Graph returns the matched CMS variation,
  // or the original page when no variation key was resolved.
}

ODP events vs FX events

This app runs two tracking pipelines that are easy to confuse. ODP events feed the behavioral profile that segments are computed from. FX events (fired by AutoTracker through the FX SDK) feed experiment metrics. They share a visitor ID but nothing else - an ODP pageview never shows up in experiment results, and an FX conversion never moves segment membership. For the conversion side, see Event Tracking.

Two pipelines, one visitor ID
// Two event pipelines run side by side in this app - don't conflate them:
//
// 1. ODP events (behavioral profile, segments, campaigns)
//    window.zaius.event("pageview")            <- OdpSetup, per route change
//    window.zaius.entity("customer", {...})    <- identity stitching
//
// 2. FX events (experiment metrics and impressions)
//    trackEvent("mb_scroll_depth", {...})      <- AutoTracker via the FX SDK
//    user.decide("flag", [])                    <- impression on render
//
// ODP events build the profile that segments are computed from.
// FX events power experiment results. Both key off the same visitor ID
// (optimizelyEndUserId) - which is exactly why OdpSetup links the IDs.

Web Experimentation → CMS Content: a Fallback Bridge #

Teams that already run Web Experimentation and then adopt a headless CMS almost always hit the same question: how do I make my WX experiments and personalization serve content from the CMS content model? It is worth slowing down here, because the instinct - bridge WX straight into Graph - is usually not the best answer. There are three ways to get a WX-style change onto the page, and the bridge is the last resort, not the default.

The root of it: WX decides in the browser, after the server has already rendered and sent the page. A server-side decision (FX or ODP-direct) happens before the response, so the CMS variant is baked into the first paint. That ordering is why the two server-side options below have no lag and no flicker, and why the bridge - reaching backwards from a client-side decision to server-rendered CMS content - always costs you one or the other.

Author it in Web Experimentation

Build the change in WX's own visual editor; the snippet mutates the page in the browser, with no CMS or Graph involvement. Its real advantage is reach - it can change anything on the rendered page, even edits nobody modeled in the CMS, without waiting on a developer. The tradeoff: the content does not live in your CMS, so if you only need changes the CMS already supports, the server-side paths are cleaner.

no CMS needed

Decide server-side, serve CMS content

When the content must come from the CMS - so editors own it and it is server-rendered and cached - make the decision before the HTML is sent. Feature Experimentation for experiments; the ODP-direct / custom paths (Paths 1-3) for personalization. The variant is in the first response - no lag, no flicker.

recommended

Bridge WX to the CMS

Only when you must keep WX as the decisioning engine and the content must come from the CMS content model. That narrow case is what the rest of this section covers - via a cookie (next request) or a client-side refetch (same view, with a flicker). Expect one of those two tradeoffs.

last resort

The bridge, for that edge case

Web Experimentation buckets visitors entirely in the browser - its snippet evaluates audience rules after the HTML has already been sent. There are two ways to connect that client-side bucket to a CMS variation; pick by goal:

  • Experiment measured over a journey - the cookie method (walked through below): WX writes the bucket to a cookie, middleware reads it on the next request and routes Graph to the variant. No flicker; the one-request lag is invisible across a multi-page journey.
  • First-touch personalization on the landing page itself - a client-side refetch: once the snippet resolves the variation, a client component refetches the variant from Graph (reusing the same variation: { include: SOME, includeOriginal: true } filter) and swaps it in on the same view. Content is fetched twice and there is a brief flicker - the same tradeoff as Approach C - Client-side only.

Identity is already shared: optimizelyEndUserId is written domain-wide by middleware and is the same cookie the Web snippet uses for visitor identity. Both products see the same visitor with no extra coordination needed. The walkthrough below implements the cookie method.

How the two-request bridge works

Request 1 - base content + WX fires

Server responds

base CMS content in HTML

WX snippet runs

evaluates experiment rules

Cookie written

opti_wx_variation=flag--key

Request 2+ - CMS variation served

Cookie sent

opti_wx_variation in headers

Middleware reads

injects __v_ URL segment

Graph filter

variation: { include: SOME }

CMS variant

or original fallback

1

Create the CMS variation in Visual Builder

In the CMS, open the page you want to experiment on and click Add variation. Name the variation to exactly match the variation key string you will write from Web Experimentation - for example treatment. The name is case-sensitive and must be an exact string match. Edit the variation's composition and publish it.
2

Configure the WX custom JS action

In the Web Experimentation UI, add a Custom JS action to your experiment - one per variation bucket. The action fires when WX assigns a visitor to that bucket. Write the cookie opti_wx_variation with the value flagKey--variationKey, where flagKey is a stable namespace you choose and variationKey is the CMS variation name from step 1. For the control/original bucket, omit the cookie write entirely - Graph's includeOriginal: true always falls back to base content when no matching variation is found.
WX custom JS action (variation bucket)
// Paste into the Custom JS action in Web Experimentation.
// Create one action per variation bucket.
// Fires when WX assigns a visitor to this variation.

var flagKey = "homepage";      // stable namespace - any string
var variationKey = "treatment"; // must exactly match CMS variation name

document.cookie =
  "opti_wx_variation=" + flagKey + "--" + variationKey +
  "; path=/; max-age=86400; SameSite=Lax";

// For the control/original bucket: omit this cookie write.
// includeOriginal: true in Graph returns base content as fallback.
src/middleware.ts (bridge - already live)
// After FX decisions are collected, middleware also reads the
// WX cookie and injects a __v_ segment for it.
// FX takes precedence: WX only applies when FX has no active
// decision for the same flagKey.

const wxVariation = request.cookies.get("opti_wx_variation")?.value;
// e.g. "homepage--treatment"

if (wxVariation && wxVariation.includes("--")) {
  const [wxFlagKey] = wxVariation.split("--");
  const covered = cmsVariationSegments.some(
    (s) => s.startsWith("__v_" + wxFlagKey + "--")
  );
  if (!covered) {
    cmsVariationSegments.push("__v_" + wxVariation);
    // /savings → /savings/__v_homepage--treatment
    // page.tsx extracts "treatment" → Graph serves the CMS variant
  }
}
3

Verify the integration in DevTools

Test without a live WX experiment by setting the cookie manually in the browser console: document.cookie = "opti_wx_variation=homepage--treatment; path=/". Navigate to the experiment page. In DevTools Network, find the HTML request - the X-Middleware-Rewrite response header should show the internal rewritten URL including __v_homepage--treatment. Clear the cookie and reload to confirm base content returns.
4

FX and Web Experimentation can coexist

FX and WX can run simultaneously on the same page. If an FX flag is active for the same flagKey, the FX decision wins and the WX cookie is ignored for that key. WX tracks its own impressions and conversions client-side via the snippet - no server-side impression tracking is needed for WX experiments.
The cookie lag suits experiments, not first-touch personalization. An experiment is measured across many visitors and multi-page journeys, so a variant that only appears from the second request is invisible in aggregate. Personalization tailors the page the visitor is on now - if the variant waits for their second view, you have already served the generic version once, and they may never return. For that, use the same-view client-side refetch, or better a server-side path (Paths 1-3).
Web Experimentation statistics are unaffected by the bridge. WX tracks its own impressions and conversions via the snippet - the server-side cookie bridge does not interfere with WX reporting. The bridge only changes which CMS content variant is served; all statistical analysis stays in the WX dashboard.

Demo: Audience Switcher #

The floating pill in the bottom-right corner lets a presenter instantly switch between audience segments without waiting for FX bucketing - useful for showing clients exactly which content each segment sees.

What it sets

The switcher writes two cookies that getVisitorContext() picks up on every subsequent server request. These map directly to FX audience conditions - no client-side SDK involved. The demo_bucketing_id cookie also serves as the FX bucketing ID, keeping the visitor in the same traffic bucket across page loads.

Persona

new_visitordemo_persona absent (default)
personaldemo_persona = "personal"
businessdemo_persona = "business"
mortgagesdemo_persona = "mortgages"
investmentsdemo_persona = "investments"

Auth State

Guestdemo_bucketing_id absent
Logged Indemo_bucketing_id = [hash]
src/lib/optimizely/visitor.ts
// Audience Switcher writes two cookies.
// Persona: POST /api/demo/set-persona → demo_persona (1-day)
// Logged In: POST /api/demo/set-bucketing-id → demo_bucketing_id
//   Value: SHA-256 of "demo-account@mosey.bank" (stable hash)

// visitor.ts reads both on every server request:
const persona     = cookieStore.get("demo_persona")?.value;
const bucketingId = cookieStore.get("demo_bucketing_id")?.value;

// demo_bucketing_id serves two roles:
//   1. logged_in: !!bucketingId  (the FX attribute)
//   2. passed to createUserContext() as bucketingId
//      for stable cross-device traffic bucketing in FX

// FX attribute map produced:
// { device: "desktop", persona: "personal", logged_in: true }
// FX audience conditions:
//   persona == "personal"  → variation key: "personal"
//   persona == "business"  → variation key: "business"
//   logged_in == true      → your custom audience
The Audience Switcher is demo tooling only. In production, replace the demo_persona cookie with real audience signals - auth session data, CRM enrichment, or onboarding answers. The FX audience conditions and targeting logic stay the same; only the attribute source changes.

Your Session #

The attributes below are what getVisitorContext() resolved for your current request. These are passed to Feature Experimentation as your audience attribute map on every page load - no round-trip, evaluated entirely in-process.

Current Attributes

User IDanonymoumous
devicedesktop
logged_infalse

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

Audience Condition Preview

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

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

See your live flag decisions on the Experimentation page

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

View your session on the FX demo →
Source files3 files
src/lib/optimizely/visitor.ts
import { cache } from "react";
import { cookies, headers } from "next/headers";
import type { FxAttributes } from "./experimentation";

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("optimizelyEndUserId")?.value ?? "anonymous";
  const ua = headerStore.get("user-agent") ?? "";
  const device = /mobile|android|iphone|ipad/i.test(ua) ? "mobile" : "desktop";
  // Strip any :port so this matches window.location.hostname on the client.
  const hostname = (headerStore.get("x-forwarded-host") ?? headerStore.get("host") ?? "").split(":")[0];
  const demoPersona = cookieStore.get("demo_persona")?.value;
  const bucketingId = cookieStore.get("demo_bucketing_id")?.value;
  const demoPageViews = cookieStore.get("demo_page_views")?.value;
  return {
    userId,
    attributes: {
      device,
      hostname,
      logged_in: !!bucketingId,
      ...(demoPersona ? { persona: demoPersona } : {}),
      ...(demoPageViews !== undefined ? { page_views: Number(demoPageViews) } : {}),
    },
    ...(bucketingId ? { bucketingId } : {}),
  };
});
src/lib/optimizely/odp.ts
const ODP_API_HOST = process.env.OPTIMIZELY_ODP_API_HOST ?? "https://api.zaius.com";
const ODP_API_KEY  = process.env.OPTIMIZELY_ODP_API_KEY  ?? "";

// Look up membership by fs_user_id: OdpSetup stitches the FX visitor id (optimizelyEndUserId)
// into ODP as fs_user_id, so the profile lives under that identifier - NOT vuid. Querying by
// vuid here returns an empty customer and no audiences ever resolve.
const SEGMENT_QUERY = `
  query GetSegments($userId: String!, $segmentFilter: [String!]!) {
    customer(fs_user_id: $userId) {
      audiences(subset: $segmentFilter) {
        edges { node { name state } }
      }
    }
  }
`;

// Lists every audience `name` defined on the ODP account. Used to build the "all audiences"
// subset for the verification panel - ODP's `audiences(subset:)` requires an explicit list.
const ALL_AUDIENCES_QUERY = `{ audiences { edges { node { name } } } }`;

async function listAudienceNames(fresh = false): Promise<string[]> {
  if (!ODP_API_KEY) return [];
  try {
    const res = await fetch(`${ODP_API_HOST}/v3/graphql`, {
      method: "POST",
      headers: { "Content-Type": "application/json", "x-api-key": ODP_API_KEY },
      body: JSON.stringify({ query: ALL_AUDIENCES_QUERY }),
      ...(fresh ? { cache: "no-store" as const } : { next: { revalidate: 3600 } }),
    });
    if (!res.ok) return [];
    const data = await res.json();
    return (data.data?.audiences?.edges ?? []).map((e: { node: { name: string } }) => e.node.name);
  } catch {
    return [];
  }
}

// Queries ODP for which of `subset` the visitor currently qualifies for. `fresh` bypasses the
// 5-min fetch cache (used by the live verification panel, which must reflect current state).
async function querySegments(userId: string, subset: string[], fresh: boolean): Promise<string[]> {
  if (!ODP_API_KEY || subset.length === 0) return [];
  try {
    const res = await fetch(`${ODP_API_HOST}/v3/graphql`, {
      method: "POST",
      headers: { "Content-Type": "application/json", "x-api-key": ODP_API_KEY },
      body: JSON.stringify({ query: SEGMENT_QUERY, variables: { userId, segmentFilter: subset } }),
      ...(fresh ? { cache: "no-store" as const } : { next: { revalidate: 300 } }),
    });
    if (!res.ok) return [];
    const data = await res.json();
    return (
      (data.data?.customer?.audiences?.edges ?? [])
        .filter((e: { node: { state: string } }) => e.node.state === "qualified")
        .map((e: { node: { name: string } }) => e.node.name)
    );
  } catch {
    return [];
  }
}

// Production path: only asks ODP about the segments in ODP_SEGMENT_TO_VARIATION (the ones that
// actually drive a homepage variation).
export async function queryOdpSegments(userId: string, fresh = false): Promise<string[]> {
  return querySegments(userId, Object.keys(ODP_SEGMENT_TO_VARIATION), fresh);
}

// Verification path: every ODP audience the visitor qualifies for, whether mapped or not. Lets
// the switcher panel show the full picture (e.g. "in active_visitors, just not a mapped one").
export async function queryAllQualifiedSegments(userId: string, fresh = false): Promise<string[]> {
  const all = await listAudienceNames(fresh);
  return querySegments(userId, all, fresh);
}

// The explicit contract between ODP audience identifiers and CMS variation names.
// This is the only place to update when either side renames something. Keys are the exact
// ODP audience `name` (case-sensitive - list them with `npx tsx scripts/test-odp.ts`);
// values are the CMS variation names, which must match the homepage CMS Variations exactly.
//
// Today the two sides are named differently (`business_banking_customer` -> `business`), so an
// explicit value is needed. When they are renamed to match (audience `business` -> variation
// `business`), you can leave the value blank ("") - resolveVariationKey falls back to the
// audience name itself, so identical naming needs no paired value. Either style works.
//
// The homepage in the CMS carries four variations: business, personal, mortgages, investments.
// ODP only has audiences for business/personal, so only those two resolve today. Mortgages and
// investments are seeded in the CMS but have no backing ODP audience yet - the variation exists
// but is never selected. That "half-configured" state is intentional (see below); create the
// audiences in ODP and add their identifiers here to light them up.
export const ODP_SEGMENT_TO_VARIATION: Record<string, string> = {
  // Identity / lifecycle (cross-session). Qualifying on mb_customer_identified (has_email=true)
  // means we recognise a known customer on a later visit - serve the welcome-back experience.
  known_customers: "returning",
  // Batch audiences (recompute on ODP's schedule - fine for returning-visitor personalization).
  business_banking_customer: "business",
  personal_banking_customers: "personal",
  // Realtime segments (evaluate on the live event stream - qualify within a session). Named
  // differently from the batch audiences above, so both are listed and either one resolves.
  business_visitors: "business",
  mortgage_visitors: "mortgages",
  // Still unmapped - no backing audience/segment yet:
  //   "<investor-audience>": "investments",
};

// Precedence when a visitor qualifies for several mapped audiences at once. Identity/lifecycle
// wins over browsing-persona, so a known customer who is also browsing business still gets the
// welcome-back experience. Any mapped segment not listed here is considered after these, in the
// order ODP returned it.
const VARIATION_PRIORITY = [
  "known_customers",
  "business_visitors",
  "business_banking_customer",
  "mortgage_visitors",
  "personal_banking_customers",
] as const;

// Resolves a qualifying ODP audience to a CMS variation key, honouring VARIATION_PRIORITY first.
// A blank map value means "the audience and variation share a name" - fall back to the audience
// name itself. The `map` parameter defaults to the module map so the logic can be unit-tested.
export function resolveVariationKey(
  segments: string[],
  map: Record<string, string> = ODP_SEGMENT_TO_VARIATION,
): string | undefined {
  for (const name of VARIATION_PRIORITY) {
    if (segments.includes(name) && name in map) return map[name] || name;
  }
  for (const segment of segments) {
    if (segment in map) return map[segment] || segment;
  }
}
src/components/OdpSetup.tsx
"use client";

import { usePathname } from "next/navigation";
import { useEffect } from "react";

import "@/lib/tracking/destinations/odp";

function getCookie(name: string): string | undefined {
  return document.cookie.match(new RegExp(`(?:^|;\\s*)${name}=([^;]*)`))?.[1];
}

export default function OdpSetup() {
  const pathname = usePathname();

  // Link the FX visitor ID to ODP once on mount so server-side segment
  // queries can use optimizelyEndUserId via the fs_user_id identifier.
  useEffect(() => {
    const fsUserId = getCookie("optimizelyEndUserId");
    if (fsUserId) window.zaius?.entity("customer", { fs_user_id: fsUserId });
  }, []);

  // Fire a pageview so ODP can qualify the visitor for pageview-based real-time segments.
  // Pass ONLY the fs_user_id identifier - do NOT pass a `page` field: the ODP Web SDK
  // auto-parses the page URL from the browser context and populates the normalized
  // "Page > URL" entity that segment rules (e.g. "Page > URL contains business") read.
  // Passing `page` manually suppresses that normalization, leaving "Page > URL" empty so
  // URL-conditioned segments match nobody. fs_user_id is stitched to the anonymous vuid via
  // the entity() call above, so the pageview is attributed to the unified customer profile.
  useEffect(() => {
    const fsUserId = getCookie("optimizelyEndUserId");
    window.zaius?.event("pageview", fsUserId ? { fs_user_id: fsUserId } : undefined);
  }, [pathname]);

  return null;
}