Variation #2Continue reading...

Developer Demo

Personalization & Audiences

Know who your visitor is at request time — device, persona, auth state, geo — before a single byte of HTML is streamed. All audience signals are collected server-side and fed into Feature Experimentation, which decides which content variant each segment receives.

✓ Zero client JSServer-side attribute collectionFeeds FX audience targeting

How Audience Targeting Works #

Before any component renders, three things happen in sequence. Every signal is collected server-side — nothing is deferred to the browser.

1

Collect signals

On every request, getVisitorContext() builds an attribute map from cookies and request headers: device from the User-Agent, persona and logged_in from cookies, plus any signals you add — geo, auth session, query params.

2

Classify against audiences

Feature Experimentation evaluates the attribute map against audience rules you define in the FX dashboard — entirely server-side with no extra network call. A visitor matching persona = "business" receives the business variation key.

3

Serve the right experience

The variation key drives what the visitor sees — a different CMS page composition, different feature variable values, or a separate layout entirely. Editors manage content variants in Visual Builder; the SDK wires audience targeting at request time with no code change per experiment.

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.

Persona

personaldemo_persona = "personal"
businessdemo_persona = "business"

Auth State

Guestdemo_logged_in = false
Logged Indemo_logged_in = true
src/lib/optimizely/visitor.ts
// Audience Switcher → POST /api/demo/set-persona
// Sets demo_persona cookie (1-day maxAge)

// visitor.ts reads it on every server request:
const persona = cookieStore.get("demo_persona")?.value;
const loggedIn =
  cookieStore.get("demo_logged_in")?.value === "true";

// Both are included in the FX attribute map:
// { device: "desktop", persona: "personal", logged_in: true }

// FX evaluates these against 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
logged_in = true
falseno match
device = "mobile"
desktopno match
device = "desktop"
desktopmatches
FX

See your live flag decisions on the Feature Experimentation page

The Feature 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 →

Audience Attributes & Targeting Criteria #

FX audiences are matched against the attributes you return from getVisitorContext(). Everything is evaluated server-side — headers, cookies, auth sessions, geo data, and any database value are available before HTML is streamed. 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";

// Included automatically in getOptimizelyUser() attributes
// FX audience condition: device = "mobile"
2

Persona (already live — set by the Audience Switcher)

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"
3

Auth session (logged-in state — also live via the switcher)

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";
import { getVisitorContext } from "@/lib/optimizely/visitor";
import { getDecision } from "@/lib/optimizely/experimentation";

const session = await getServerSession();
const { userId: cookieId, attributes } = await getVisitorContext();

// Use account ID for stable cross-device bucketing
const userId = session?.user?.id ?? cookieId;

const decision = await getDecision("premium_feature", userId, {
  ...attributes,
  logged_in:  Boolean(session),
  plan:       session?.user?.plan ?? "free",
  role:       session?.user?.role ?? "guest",
});
// 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 { userId, attributes } = await getVisitorContext();

  const decision = await getDecision("campaign_hero", userId, {
    ...attributes,
    utm_source:   sp.utm_source ?? "direct",
    utm_medium:   sp.utm_medium ?? "none",
    utm_campaign: sp.utm_campaign ?? "none",
  });
  // FX audience: utm_source = "google"
}
6

Combining attributes — audience conditions in FX

All attributes are available as conditions in the FX dashboard. Combine them with AND/OR/NOT to create precise segments. The SDK evaluates conditions locally against the attribute map — no network call per decision.

  • String match: persona = "business"
  • Boolean: logged_in = true
  • Substring: plan contains "premium"
  • Numeric range: account_age_days > 30
// Pass everything you know about the user
const { userId, attributes } = await getVisitorContext();
const decision = await getDecision("homepage_audience", userId, {
  // From getVisitorContext() (device, persona, logged_in)
  ...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",
});
// FX evaluates ALL of these server-side.
// Zero client-side data exposure.

How This Connects to Feature Experimentation #

Audience attributes are the bridge between visitor identity and experiment bucketing. The signals you collect here flow directly into the FX SDK, which decides which variation key a visitor receives — and that key determines which CMS content variant Graph returns.

From audience signal to personalized CMS content

Audience signals

device, persona, geo, auth

getVisitorContext()

FX SDK

evaluates audience rules

→ variationKey

Graph query

variation filter applied

getContentByPath()

CMS variant

matched by variation name

or original fallback

P

This page covers

  • Collecting audience signals (device, persona, geo, auth)
  • Extending getVisitorContext() with new attribute sources
  • FX audience condition types — string, boolean, numeric, substring
  • The Audience Switcher demo tool and the cookies it sets
  • Creating flags, experiments, and variation keys in the FX dashboard
  • Connecting FX variation keys to CMS content variations in Visual Builder
  • Feature variables — typed values delivered per variation
  • Impression events, experiment results, and winner declaration
  • Your live flag decisions and active variation keys

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 Feature 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 →
Source files2 files
src/lib/optimizely/user.ts
import { cache } from "react";
import { OptimizelyDecideOption } from "@optimizely/optimizely-sdk";
import { getOptimizelyClient } from "./experimentation";
import type { FxDecision, FxAttributes } from "./experimentation";
import { getVisitorContext } from "./visitor";

type DecideOpts =
  | OptimizelyDecideOption[]
  | { options?: OptimizelyDecideOption[]; bucketingId?: string; attributes?: FxAttributes };

function resolveOpts(opts: DecideOpts | undefined): {
  sdkOptions: OptimizelyDecideOption[];
  bucketingId?: string;
  attributes?: FxAttributes;
} {
  if (!opts || Array.isArray(opts)) {
    return { sdkOptions: opts ?? [OptimizelyDecideOption.DISABLE_DECISION_EVENT] };
  }
  return {
    sdkOptions: opts.options ?? [OptimizelyDecideOption.DISABLE_DECISION_EVENT],
    bucketingId: opts.bucketingId,
    attributes: opts.attributes,
  };
}

const noDecision = (flagKey: string): FxDecision => ({
  flagKey, enabled: false, variationKey: null, variables: {}, reasons: [],
});

const noOpUser = {
  userId: "anonymous" as string,
  bucketingId: undefined as string | undefined,
  decide: (flagKey: string, _opts?: DecideOpts): FxDecision => noDecision(flagKey),
  decideAll: (): Record<string, FxDecision> => ({}),
};

export const getOptimizelyUser = cache(async () => {
  const [client, { userId, attributes, bucketingId }] = await Promise.all([
    getOptimizelyClient(),
    getVisitorContext(),
  ]);

  if (!client) return { ...noOpUser, userId, bucketingId };

  const ctx = client.createUserContext(userId, attributes);
  if (!ctx) return { ...noOpUser, userId, bucketingId };

  return {
    userId,
    bucketingId,
    decide(flagKey: string, opts?: DecideOpts): FxDecision {
      const { sdkOptions, bucketingId: bId, attributes: attrOverrides } = resolveOpts(opts);
      const activeCtx = bId || attrOverrides
        ? client.createUserContext(userId, {
            ...attributes,
            ...attrOverrides,
            ...(bId ? { $opt_bucketing_id: bId } : {}),
          }) ?? ctx
        : ctx;
      const d = activeCtx.decide(flagKey, sdkOptions);
      return {
        flagKey,
        enabled: d.enabled,
        variationKey: d.variationKey,
        variables: d.variables as Record<string, unknown>,
        reasons: d.reasons,
      };
    },
    decideAll(): Record<string, FxDecision> {
      const raw = ctx.decideAll([OptimizelyDecideOption.DISABLE_DECISION_EVENT]);
      const out: Record<string, FxDecision> = {};
      for (const [key, d] of Object.entries(raw)) {
        out[key] = {
          flagKey: key,
          enabled: d.enabled,
          variationKey: d.variationKey,
          variables: d.variables as Record<string, unknown>,
          reasons: d.reasons,
        };
      }
      return out;
    },
  };
});
src/app/[[...slug]]/page.tsx
import { notFound } from "next/navigation";
import type { Metadata } from "next";
import { getClient } from "@optimizely/cms-sdk";
import { OptimizelyComponent, withAppContext } from "@optimizely/cms-sdk/react/server";
import { initComponentRegistry } from "@/lib/optimizely/componentRegistry";
import { GET_ALL_PAGE_PATHS_QUERY } from "@/lib/graphql/queries/GetAllPagePaths";
import { graphqlFetch } from "@/lib/optimizely/client";
import { getOptimizelyUser } from "@/lib/optimizely/user";

// Registers all content types, display templates, and React components.
// Also calls config() so getClient() works throughout the app.
initComponentRegistry();

// Force SSR so Graph queries are always fresh and never served from a stale
// build-time data cache. Without this, Next.js caches the first (possibly empty)
// Graph response at build time and serves it on Vercel until a redeploy.
export const dynamic = "force-dynamic";

interface PageParams {
  slug?: string[];
}

const LOCALE_PREFIX_RE = /^[a-z]{2}(-[a-z]{2})?$/;

const KEY_QUERY = /* GraphQL */ `
  query FindPageKey($urls: [String]) {
    _Page(
      where: { _metadata: { url: { default: { in: $urls } } } }
      limit: 10
    ) {
      items { _metadata { key version variation } }
    }
  }
`;

function buildUrlCandidates(slug?: string[]): string[] {
  // Root "/" — no slug — defaults to the English homepage
  if (!slug || slug.length === 0) {
    return ["/", "/en/", "/en/homepage/"];
  }
  const path = slug.join("/");
  // If the first segment is a locale code the URL is already fully qualified
  if (LOCALE_PREFIX_RE.test(slug[0])) {
    const locale = slug[0];
    // A bare locale slug (e.g. ["en"]) is the locale homepage.
    if (slug.length === 1) {
      // The English start page is stored at "/" in Graph (the CMS start page has no locale prefix).
      if (locale === "en") return [`/${path}/`, `/${path}/homepage/`, "/"];
      // Non-English locale homepage: try locale-specific first, fall back to English start page.
      return [`/${path}/`, `/${path}/homepage/`, "/en/", "/en/homepage/", "/"];
    }
    // For locale + path (e.g. ["en", "savings"]), try the locale-prefixed URL first.
    // Also try the bare path because the CMS sometimes omits the locale prefix for English pages.
    const rest = slug.slice(1).join("/");
    if (locale === "en") {
      return [`/${path}/`, `/${rest}/`];
    }
    // Non-English: try locale URL first, then fall back to English equivalents.
    return [`/${path}/`, `/en/${rest}/`, `/${rest}/`];
  }
  // Legacy English paths without locale prefix (e.g. /savings from generateStaticParams
  // stripping /en/ in earlier builds) — try both prefixed and bare.
  return [`/en/${path}/`, `/${path}/`];
}

async function CmsPage({
  params,
}: {
  params: Promise<PageParams>;
  searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
  const { slug } = await params;
  const urls = buildUrlCandidates(slug);

  const user = await getOptimizelyUser();
  const fxDecisions = user.decideAll();

  const activeVariations = Object.values(fxDecisions)
    .filter((d) => d.enabled && d.variationKey && d.variationKey !== "off")
    .map((d) => d.variationKey as string);

  const client = getClient();

  // When a persona is active, pass the variation names as a filter so Graph
  // returns both the matching variation and the base (includeOriginal: true).
  // We then prefer the variation item from the returned array.
  const variationFilter =
    activeVariations.length > 0
      ? { variation: { include: "SOME" as const, value: activeVariations, includeOriginal: true } }
      : undefined;

  let page: any = null;

  // Step 1: URL-based lookup. Graph returns one item for pages with a single
  // published version; for multi-version pages (e.g. homepage) it returns all
  // matching versions so we pick the variation match.
  // cache: false bypasses Graph's own server-side CDN cache (?cache=false).
  for (const url of urls) {
    const items = await client.getContentByPath(url, { ...variationFilter, cache: false });
    if (items.length > 0) {
      const variationMatch = variationFilter
        ? items.find((item: any) => activeVariations.includes(item._metadata?.variation))
        : null;
      page = variationMatch ?? items[0];
      break;
    }
  }

  // Step 2: Fallback for pages where getContentByPath returns nothing because
  // _Content.item resolves to null when multiple items share the same URL.
  // _Page.items has no such restriction — use it to find key+variation by name,
  // then fall back to the highest base version.
  if (!page) {
    const keyResult = await graphqlFetch<{
      _Page: { items: Array<{ _metadata: { key: string; version: string | number; variation: string | null } }> };
    }>(KEY_QUERY, { urls }, { cache: "no-store" });

    const candidates = (keyResult.data?._Page?.items ?? [])
      .map((i) => i._metadata)
      .filter((m): m is { key: string; version: string | number; variation: string | null } => !!(m?.key && m?.version));

    // Prefer a version whose variation name matches the active persona,
    // fall back to the highest base version (no variation name).
    const variationMatch = candidates.find(
      (m) => m.variation != null && activeVariations.includes(m.variation)
    );
    const baseFallback = candidates
      .filter((m) => !m.variation)
      .sort((a, b) => Number(b.version) - Number(a.version))[0];

    const meta = variationMatch ?? baseFallback;
    if (meta) {
      page = await client.getContent(
        { key: meta.key, version: String(meta.version) },
        { cache: false }
      );
    }
  }

  if (!page) {
    return notFound();
  }

  // Fire the real FX impression when Graph served a variation.
  const servedVariation: string | null = page._metadata?.variation ?? null;
  if (servedVariation) {
    const exposedFlag = Object.values(fxDecisions).find(
      (d) => d.variationKey === servedVariation
    );
    if (exposedFlag) {
      void user.decide(exposedFlag.flagKey, []);
    }
  }

  return <OptimizelyComponent content={page} />;
}

export default withAppContext(CmsPage);

/** Pre-render all known CMS page paths at build time */
export async function generateStaticParams(): Promise<PageParams[]> {
  let result;
  try {
    result = await graphqlFetch<any>(
      GET_ALL_PAGE_PATHS_QUERY,
      undefined,
      { next: { revalidate: 3600 } }
    );
  } catch {
    return [];
  }

  const pages = result.data?._Page?.items ?? [];

  return pages
    .map((page: any) => {
      const url: string = page?._metadata?.url?.default ?? "";
      if (!url || url === "/") return { slug: undefined };

      // English homepage variants → root route (no slug)
      if (url === "/en/" || url === "/en/homepage/") return { slug: undefined };

      // For English pages, strip the /en/ prefix so URLs stay clean (/savings not /en/savings).
      // For all other locales, keep the full path (/nl/savings stays /nl/savings).
      const locale = url.split("/").filter(Boolean)[0] ?? "";
      const effective =
        locale === "en" ? url.replace(/^\/en\//, "/") : url;

      const segments = effective.replace(/^\/|\/$/g, "").split("/").filter(Boolean);
      if (segments.length === 0) return { slug: undefined };
      return { slug: segments };
    })
    .filter(Boolean);
}

const GET_PAGE_META_QUERY = /* GraphQL */ `
  query GetPageMeta($urls: [String]) {
    _Page(
      where: { _metadata: { url: { default: { in: $urls } } } }
      limit: 1
    ) {
      items {
        _metadata {
          displayName
        }
      }
    }
  }
`;

export async function generateMetadata({
  params,
}: {
  params: Promise<PageParams>;
}): Promise<Metadata> {
  const { slug } = await params;
  const urls = buildUrlCandidates(slug);

  const result = await graphqlFetch<any>(GET_PAGE_META_QUERY, { urls }, { next: { revalidate: 300 } });
  const page = result.data?._Page?.items?.[0];

  return {
    title: page?._metadata?.displayName ?? "Page",
  };
}