Developer Demo
Optimizely Data Platform
The Direct Path Is the Main One #
ODP answers one question - who is this visitor, based on everything they have ever done - and it answers it server-side, before the response. That makes it unusually well suited to CMS personalization: query the visitor's segments, map one to a variation key, hand the key to Graph. Nothing buckets, nothing splits traffic, and nothing needs to be measured, because this is personalization rather than an experiment.
ODP is also an audience source for the other two products, and those are documented on their own pages. Reach for them when you need what they add - statistics, or a marketer-owned visual editor - not to get at ODP data.
Direct · this page
ODP → Graph
Segments queried server-side, mapped to a variation key, served in the first response. No experiment, no flicker, no extra request.
As an FX audience
ODP → FX → Graph
Use an ODP segment as an FX audience condition when you want the decision measured. Adds statistics; costs a bucketing step.
As a WX attribute
ODP → WX → CMS
Push the segment in as a WX user attribute so a marketer can target it from the visual editor with no deploy.
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. Four things flow through it: an event tag in the page head, identity stitching that links ODP's cookie to the FX visitor ID, a server-side query that reads segment membership back out, and customer-attribute writes that put top_category and last_category on the profile. That last one is what lets a marketer build an audience on a CMS taxonomy term from a dropdown instead of filing an engineering ticket. 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.
// A client component mounted once 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. Surfaces that have to show current state rather than cached state, like the audience switcher panel, ask for a profile endpoint with a cache-bypass flag, which bypasses that cache.
// 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).
// Look up by fs_user_id: the identity component stitches the visitor id into ODP under
// that identifier. Querying by vuid returns an empty customer.
const SEGMENT_QUERY = `
query GetSegments($userId: String!, $segmentFilter: [String!]!) {
customer(fs_user_id: $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
Segment lookup
subset filtered, cached
Resolve variation
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. stored: false is
// required too: a Graph stored query template binds the variation value
// of the request that registered it, so every visitor would be served
// whichever variation was asked for first.
const variationFilter = variationKey
? {
variation: { include: "SOME" as const, value: [variationKey], includeOriginal: true },
stored: false,
}
: 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
There are two ways an event leaves this app, and the difference is narrower than it looks. Pageviews are ODP-only: the identity component calls the zaius tag directly, so a pageview feeds the behavioral profile and never appears in experiment results. Everything else goes through one trackEvent() call that fans out to all three destinations - FX, ODP and the dataLayer. So a conversion does move segment membership: the same call that records an experiment metric also lands on the ODP profile a segment is computed from. Each of those events now also carries exp_variant_string, so the variation the visitor saw travels with it. For the fan-out itself, see Event Tracking.
// Two ways an event leaves this app. Only the first is ODP-only.
//
// 1. Straight to ODP - never reaches experiment results
// window.zaius.event("pageview") <- identity component, per route
// window.zaius.entity("customer", {...}) <- identity stitching + top_category
//
// 2. One call, three destinations - NOT "the FX SDK"
// trackEvent("scroll_depth", {...}) <- the tracking listener
// -> FX: user.trackEvent(key, tags) experiment metrics
// -> ODP: zaius.event(key, {...tags}) moves segment membership
// -> dataLayer: window.dataLayer.push({...}) GA4 / GTM
//
// Every one of those carries exp_variant_string, so the variation the
// visitor was served travels with the event to all three.
//
// Decision events are separate again: user.decide("flag", []) on render.
// Everything keys off the same visitor ID (optimizelyEndUserId) - which is
// exactly why the identity component stitches it into ODP as fs_user_id.Demo: Settings Panel #
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. It also carries the WX delivery switch described above, and reports which CMS variation the Web Experimentation bridge resolved on the current page.
What it sets
The switcher writes two cookies that the request-scoped visitor context picks up on every subsequent server request. These map directly to FX audience conditions - no client-side SDK involved. The bucketing_id cookie also serves as the FX bucketing ID, keeping the visitor in the same traffic bucket across page loads.
Persona
new_visitorpersona absent (default)personalpersona = "personal"businesspersona = "business"mortgagespersona = "mortgages"investmentspersona = "investments"Auth State
// Audience Switcher writes two cookies.
// Persona: POST /api/demo/set-persona → persona (1-day)
// Logged In: POST /api/demo/set-bucketing-id → bucketing_id
// Value: SHA-256 of "demo-account@mosey.bank" (stable hash)
// visitor.ts reads both on every server request:
const persona = cookieStore.get("persona")?.value;
const bucketingId = cookieStore.get("bucketing_id")?.value;
// 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 audiencepersona 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.How this reference implementation does it3 files
export const ODP_API_HOST = process.env.OPTIMIZELY_ODP_API_HOST ?? "https://api.zaius.com";
export 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/maintenance/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 variations for returning, business, personal, mortgages and
// investments. Every one except investments has a backing ODP audience or segment below; the
// investments variation is seeded in the CMS but never selected until an audience exists. That
// "half-configured" state is intentional - create the audience in ODP and add its identifier
// here to light it 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";
import { readCookie } from "@/lib/tracking/cookies";
import { VISITOR_ID_COOKIE } from "@/lib/optimizely/cookieNames";
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 = readCookie(VISITOR_ID_COOKIE);
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 = readCookie(VISITOR_ID_COOKIE);
window.zaius?.event("pageview", fsUserId ? { fs_user_id: fsUserId } : undefined);
}, [pathname]);
return null;
}
import "server-only";
import { cache } from "react";
import {
createInstance,
createStaticProjectConfigManager,
createForwardingEventProcessor,
createLogger,
createOdpManager,
eventDispatcher,
OptimizelyDecideOption,
ERROR,
type Client,
} from "@optimizely/optimizely-sdk";
import { fetchDatafile } from "./datafile";
import type { FxDecision } from "./experimentation";
import { getVisitorContext } from "./visitor";
// FX with its native ODP integration switched on, so an FX audience can target an
// ODP segment directly (ODP_SEGMENT conditions) instead of going through the
// hand-rolled ODP_SEGMENT_TO_VARIATION map in odp.ts.
//
// This is a PARALLEL factory on purpose. getOptimizelyClient() in experimentation.ts
// and getOptimizelyUser() in user.ts are untouched, so middleware, every existing
// server component and FxBucketingEvent keep behaving exactly as before.
//
// WHERE THIS MAY BE USED: route handlers, the dynamic homepage branch, /demo/* pages.
// NOT in src/middleware.ts - fetchQualifiedSegments() is an awaited HTTPS round trip
// to ODP, and middleware runs on every request including prefetches under a 3s budget.
// NOT in the browser - the zaius tag already owns browser-side ODP identity, and a
// second consumer would create two sources of truth for fs_user_id.
//
// The ODP API key and host come from the FX DATAFILE (the `integrations` entry written
// when ODP is enabled under FX project Settings -> Integrations), not from env vars, so
// no new credential is needed. With the integration off, fetchQualifiedSegments()
// simply resolves false and decide() still works normally.
export const getOdpAwareClient = cache(async function getOdpAwareClient(): Promise<Client | null> {
const datafileText = await fetchDatafile();
if (!datafileText) return null;
return (
createInstance({
projectConfigManager: createStaticProjectConfigManager({ datafile: datafileText }),
logger: createLogger({ level: ERROR }),
eventProcessor: createForwardingEventProcessor(eventDispatcher),
odpManager: createOdpManager({
// Mirrors the 5-minute posture odp.ts already uses for segment reads.
segmentsCacheTimeout: 300_000,
// Hard ceiling: a slow ODP must never stall a render.
segmentsApiTimeout: 2_000,
}),
}) ?? null
);
});
export type SegmentAwareUser = {
userId: string;
/** ODP audiences this visitor qualifies for. Empty when the integration is off. */
qualifiedSegments: string[];
isQualifiedFor(segment: string): boolean;
/** Impression-suppressed by default, matching getDecision() in experimentation.ts. */
decide(flagKey: string, options?: OptimizelyDecideOption[]): FxDecision;
};
export const getSegmentAwareUser = cache(async function getSegmentAwareUser(): Promise<SegmentAwareUser | null> {
const [client, { userId, attributes, bucketingId }] = await Promise.all([
getOdpAwareClient(),
getVisitorContext(),
]);
if (!client || userId === "anonymous") return null;
const ctx = client.createUserContext(userId, attributes);
if (!ctx) return null;
if (bucketingId) ctx.setAttribute("$opt_bucketing_id", bucketingId);
// Resolves false when the ODP integration is off or the call fails. Either way we
// fall through to an empty segment list rather than null, so callers need no
// special-case branch.
await ctx.fetchQualifiedSegments().catch(() => false);
return {
userId,
qualifiedSegments: ctx.qualifiedSegments ?? [],
isQualifiedFor: (segment: string) => ctx.isQualifiedFor(segment),
decide(flagKey: string, options = [OptimizelyDecideOption.DISABLE_DECISION_EVENT]) {
const d = ctx.decide(flagKey, options);
return {
flagKey,
enabled: d.enabled,
variationKey: d.variationKey,
variables: d.variables as Record<string, unknown>,
reasons: d.reasons,
};
},
};
});