Developer Demo
Personalization & Audiences
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.
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-nativeAttributes 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.
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 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.
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 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"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 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"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 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.
Add the signal to getVisitorContext()
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
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// 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",
};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.
// 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 ("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 - 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
// 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 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 neededDecide 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.
recommendedBridge 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 resortThe 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
Create the CMS variation in Visual Builder
treatment. The name is case-sensitive and must be an exact string match. Edit the variation's composition and publish it.Configure the WX custom JS action
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.// 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.// 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
}
}Verify the integration in DevTools
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.FX and Web Experimentation can coexist
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.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
// 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 audiencedemo_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
anonymou…mousdesktopfalseNo 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 →Source files3 files
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 } : {}),
};
});
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;
}
}
"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;
}