<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom"><title type="text">Blog posts by Piotr Nowak - Optimizely &amp; Azure</title><link href="https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/" /><updated>2026-10-29T13:16:53.0000000Z</updated><id>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/</id><generator uri="https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/" version="2.0">Optimizely World</generator><entry><title>Visual Builder without Next.js, Part 1: What Astro Has to Rebuild on CMS 13 PaaS</title><link href="https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/10/visual-builder-without-next.js-part-1-what-astro-has-to-rebuild-on-cms-13-paas" /><id>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/10/visual-builder-without-next.js-part-1-what-astro-has-to-rebuild-on-cms-13-paas</id><published>2026-10-29T13:16:53.0000000Z</published><updated>2026-10-29T13:16:53.0000000Z</updated><author><name>piotr.n@rockwool.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;&lt;em&gt;Series:&lt;/em&gt; &lt;strong&gt;Part 1: What Astro Has to Rebuild on CMS 13 PaaS&lt;/strong&gt; &amp;middot; Part 2: Does It Survive DXP? (Redis-backed ISR, Graph webhooks, Cloudflare and preview at scale)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Status:&lt;/strong&gt; built and tested in October 2026 against an Alloy-based CMS 13.4.2, then 13.5.0, with Optimizely Graph, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;@optimizely/cms-sdk&lt;/span&gt; and &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;@optimizely/cms-cli&lt;/span&gt; 3.1.0-beta.0, Astro 7.4.2 and Node 24.19.0. The three problems that cost the most were reproduced again on 29 October against the packages as published on npm. Everything here is either measured, read in the SDK's source and guides, or a design choice; where it matters, the text says which. The code, the checks and the CMS setup notes are in the companion repository, &lt;a href="https://github.com/pino-labs/astro-visual-builder-cms13"&gt;pino-labs/astro-visual-builder-cms13&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;The question&lt;/h2&gt;
&lt;p&gt;Do we have to pick Next.js to get Visual Builder? No. Astro renders it. But rendering was the easy part. Taking Next.js away also takes away what it quietly does around the SDK, and each of those pieces has to be supplied by hand: who owns the content model, how a composition becomes HTML, how a draft reaches the preview frame, which application a path belongs to, and how one editor's preview stays out of a visitor's page.&lt;/p&gt;
&lt;p&gt;"Without Next.js" does not mean without React: the demo deliberately mixes &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;.astro&lt;/span&gt;, React and Svelte components behind one registry. This part is about what had to be rebuilt. Part 2 asks whether the same architecture survives production on DXP: Redis-backed ISR, Graph webhooks, Cloudflare and preview at scale.&lt;/p&gt;
&lt;h2&gt;What changed, and what I found in practice&lt;/h2&gt;
&lt;p&gt;Two releases opened the door. On 31 August Optimizely added Astro to DXP front-end hosting, and on 24 September the CMS (SaaS) release notes for JavaScript SDK 3.0 called the SDK core framework-neutral and published an Astro sample. The SDK's README still positions React and Next.js as its first-class integrations; what the core offers Astro is primitives, not an integration.&lt;/p&gt;
&lt;p&gt;The version boundary matters in practice. At the time of testing, the framework-neutral entry point, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;@optimizely/cms-sdk/core&lt;/span&gt;, and the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;platform: 'paas'&lt;/span&gt; option were only available in the 3.1.0 beta: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;latest&lt;/span&gt; is 3.0.2, and its package exports neither. In the packages I inspected, both first appear in 3.1.0-beta.0, published on 20 October under the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;beta&lt;/span&gt; tag. The Astro sample takes the SDK from its own monorepo (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;workspace:*&lt;/span&gt;). Copy it into a project with 3.0.2 and its Svelte route, which is built on &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;@optimizely/cms-sdk/core&lt;/span&gt;, does not resolve, because 3.0.2 does not export that entry point. Everything below runs on the beta, pinned to the exact version; when 3.1.0 ships, re-run the checks before you upgrade.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Before you start:&lt;/strong&gt; CMS 13 PaaS with Graph, 13.4.2 or later, and Node 22.23.4 or later on the 22 line, or Node 24 or 26 (Astro 7.4.0 raised the floor to 22.22.0, and the 7.4.2 patch to 22.23.4). The 13.4.2 minimum is not an Astro requirement: 13.4.1 fixes a culture-specific data-loss bug (CMS-57780), and the 13.4.2 database update restores the values lost before the fix. I tested on CMS 13.4.2 and 13.5.0 with Astro 7.4.2 on Node 24.19.0. DXP front-end hosting is needed only for the production checks in part 2. This is not a CMS 12 tutorial: the implementation depends on the CMS 13 REST API and the CMS 13 Graph schema.&lt;/p&gt;
&lt;h2&gt;The architecture&lt;/h2&gt;
&lt;p&gt;CMS 13 owns composition: editors arrange sections, rows, columns and elements in Visual Builder and pick display templates. Graph serves that composition as data. Astro turns it into HTML through one component registry, where &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;.astro&lt;/span&gt;, React and Svelte components sit side by side and render on the server; only components marked as islands (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;client:*&lt;/span&gt;) are hydrated in the browser.&lt;/p&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/visual-builder-without-nextjs-part-1/architecture-light.svg" alt="Figure 1: The architecture" /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;Figure 1. CMS 13 owns the composition, Graph serves it as data, Astro renders it through one registry. Only components marked as islands are hydrated in the browser.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h3&gt;Who owns the content model?&lt;/h3&gt;
&lt;p&gt;This is the first architectural decision, and CMS 13 gives you two answers. A content type can be owned by C# and mirrored into TypeScript, or owned by TypeScript and pushed into the CMS.&lt;/p&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/visual-builder-without-nextjs-part-1/who-owns-the-type-light.svg" alt="Figure 2: Who owns the content model" /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;Figure 2. Two directions: C# owns the type and &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;config pull&lt;/span&gt; mirrors it, or TypeScript owns the type and &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;config push&lt;/span&gt; creates it. One source of truth per type.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Both work. Pick one per type. The CMS REST API treats code-managed types as read-only: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;config pull&lt;/span&gt; gets them, and generates typed files for them, only with &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;--includeReadOnly&lt;/span&gt;, and the CMS rejects a push that includes one. My recommendation after testing both models: keep legacy page types in C#, model everything Visual Builder needs (experiences, sections, elements, display templates) in the frontend repository, and regenerate the TypeScript mirror of the C# types with &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;config pull&lt;/span&gt;. In CLI 3.1.0-beta.0 that has to happen in a terminal (ask me how I know): without one, the CLI prints the manifest as JSON and writes no files, even with &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;--output&lt;/span&gt;. The fix (CMS-57658) is on the SDK's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;main&lt;/span&gt; branch, so with the next CLI release CI can regenerate the mirror too.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One source of truth per type, never two.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;On the Alloy-based CMS I tested against, the rule was not theoretical. Alloy ships a C# &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;ArticlePage&lt;/span&gt;, the same name as the TypeScript &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;ArticlePage&lt;/span&gt;, so I renamed the C# type to &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;AlloyArticlePage&lt;/span&gt; before the first push. Same GUID, content untouched.&lt;/p&gt;
&lt;p&gt;Optimizely's FAQ for CMS 13.0.0 recommends managing types where they are primarily used, names .NET code as the typical case for PaaS headless, and calls full PaaS headless support an area of ongoing development. API-managed Visual Builder types worked in the CMS 13 setup I tested; the broader PaaS headless story is still evolving. Two PaaS gotchas are easy to miss. &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;OPTIMIZELY_CMS_URL&lt;/span&gt; is the runtime and preview URL, while the CLI talks to the CMS REST API through &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;OPTIMIZELY_CMS_API_URL&lt;/span&gt; (or &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;--host&lt;/span&gt;). And the token the CMS issues to the CLI's OAuth client carries the client id and the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;api:admin&lt;/span&gt; scope, but no roles (decoded on 13.4.2, with a client defined in configuration): enough for content types, display templates and the application, but &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;config push&lt;/span&gt; also creates the application's start content, which is checked against the content ACL. On a root that grants Everyone only Read, the push stops with a 403 until the client gets Read and Create there. Everything on the CMS side (the Graph instance, the OAuth client and its access, the Headless application and its preview URL) is in the repository's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;web/docs/cms13-setup.md&lt;/span&gt;; the code paths below are relative to &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;web/&lt;/span&gt;.&lt;/p&gt;
&lt;h3&gt;Configure the SDK for PaaS&lt;/h3&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;// src/lib/optimizely.ts (excerpt)
  config({
    apiKey: OPTIMIZELY_GRAPH_SINGLE_KEY,
    graphUrl: OPTIMIZELY_GRAPH_GATEWAY, // a URL with no path gets /content/v2 appended
    platform: 'paas', // CMS 13 PaaS; the SDK does not detect the product. Needs SDK &amp;gt;= 3.1.0-beta.0.
    fragment: { richTextFormat: 'both' }, // 3.x fetches json only by default
    // Path lookups also match _metadata.url.base. Without it every application on the CMS answers for
    // the same path, and the first hit may be another site's start page. The value is the Headless
    // application's primary host as Graph stores it, scheme included (https://localhost:3001).
    ...(OPTIMIZELY_APPLICATION_HOST ? { query: { host: OPTIMIZELY_APPLICATION_HOST } } : {}),
  });
&lt;/pre&gt;
&lt;p&gt;The SDK does not infer the hosting model from the Graph endpoint, so select the PaaS platform explicitly; the SDK's PaaS guide gives the reason: without it, queries that use a SaaS-only feature fail against PaaS. In 3.1.0-beta.0 the two adapters still build the same queries. A comment in the SDK source says why the PaaS adapter is a copy of the SaaS one: so that SaaS changes don't reach PaaS.&lt;/p&gt;
&lt;p&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;query.host&lt;/span&gt; matters as soon as the CMS has more than one application. Astro often joins a CMS that already serves a site, as a second application. Every application's start page then matches the same path. The SDK's troubleshooting guide covers this case (&lt;em&gt;I get content from the wrong site&lt;/em&gt;); on my test CMS the symptom was less obvious: the first hit was Alloy's start page, a type Astro does not register. The page rendered empty. Bind the Headless application's host to a language as well (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;locale: 'en'&lt;/span&gt; in &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;optimizely.config.mjs&lt;/span&gt;). Otherwise CMS 13 puts the start page at &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;/en/&lt;/span&gt;.&lt;/p&gt;
&lt;h2&gt;Rendering Visual Builder without React&lt;/h2&gt;
&lt;p&gt;This is the heart of the series. The official sample's main routes render a composition with React's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;OptimizelyComposition&lt;/span&gt; from &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;@optimizely/cms-sdk/react/server&lt;/span&gt;, which renders every node through the async server component &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;OptimizelyComponent&lt;/span&gt;. It works, but it hands the whole page to one React render, and Astro cannot place an island anywhere inside it. The sample's newer &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;/svelte/&lt;/span&gt; route uses the core's planning, but again hands the whole page to one framework and does not cover preview. The core entry point exposes the same planning as plain functions, so Astro can own the page instead.&lt;/p&gt;
&lt;p&gt;The bridge is one registry for every framework:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;// src/lib/astroRegistry.ts
import { ComponentRegistry } from '@optimizely/cms-sdk/core';
import LandingExperience from '@/components/LandingExperience.astro';
import ArticlePage from '@/components/ArticlePage.astro';
import BlankSection from '@/components/BlankSection.astro';
import Hero from '@/components/react/Hero';
import Tile, { SquareTile } from '@/components/react/Tile';
import CallToAction from '@/components/react/CallToAction';
import MonthlySpecial from '@/components/svelte/MonthlySpecial.svelte';
import FAQIsland from '@/components/islands/FAQIsland.astro';
import Spotlight from '@/components/Spotlight.astro';

// One registry for .astro, React and Svelte components. Everything renders on the server;
// the only client JavaScript comes from the wrappers in src/components/islands/.
export const registry = new ComponentRegistry({
  LandingExperience,
  ArticlePage,
  BlankSection,
  Hero,
  Tile: { default: Tile, tags: { Square: SquareTile } },
  CallToAction,
  MonthlySpecial,
  FAQ: FAQIsland,
  Spotlight,
});
&lt;/pre&gt;
&lt;p&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;resolveContentComponent(content, { registry })&lt;/span&gt; looks components up in this registry and nothing else (passing a registry replaces the SDK's global component registries, with no fallback), so nothing in the application imports &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;@optimizely/cms-sdk/react/server&lt;/span&gt;. A display template with a tag selects a variant. When the &lt;em&gt;Square&lt;/em&gt; style is picked on a tile, the node carries that display template's key. The planner (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;planGridSection()&lt;/span&gt; for a tile inside a section, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;planComposition()&lt;/span&gt; at the top level) maps it to the tag &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Square&lt;/span&gt; through the templates registered with &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;initDisplayTemplateRegistry()&lt;/span&gt;. That registry is global, and the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;registry&lt;/span&gt; option does not replace it. &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;tags.Square&lt;/span&gt; renders the tile.&lt;/p&gt;
&lt;p&gt;The composition itself is about twenty lines of Astro:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;---
// src/components/OptimizelyComposition.astro
import { planComposition, resolveContentComponent, isWrappedComponent, type ExperienceNode } from '@optimizely/cms-sdk/core';
import { registry } from '@/lib/astroRegistry';

interface Props { nodes: ExperienceNode[] }
const { nodes } = Astro.props;
const items = planComposition(nodes);
---
{items.map((item) =&amp;gt; {
  if (item.kind === 'unknown') return null;
  const r = resolveContentComponent(item.content, { tag: item.tag, registry });
  if (!r.component) return null;
  const Component = r.component as any;
  const props = { content: r.contentProps, displaySettings: item.displaySettings };
  // A component node gets the preview wrapper; a section node carries its own data-epi-* attributes.
  return isWrappedComponent(item)
    ? &amp;lt;div {...(item.previewAttrs ?? {})}&amp;gt;&amp;lt;Component {...props} /&amp;gt;&amp;lt;/div&amp;gt;
    : &amp;lt;Component {...props} {...(item.previewAttrs ?? {})} /&amp;gt;;
})}
&lt;/pre&gt;
&lt;p&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;planComposition()&lt;/span&gt; turns the experience's nodes into a flat plan; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;isWrappedComponent()&lt;/span&gt; separates component nodes, which get a &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;&amp;lt;div&amp;gt;&lt;/span&gt; carrying the preview attributes, from sections, which take the attributes themselves. Sections hand their rows and columns to &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;planGridSection()&lt;/span&gt;, and a recursive &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;GridItems.astro&lt;/span&gt; renders the grid; row and column display templates arrive as &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;displaySettings&lt;/span&gt; and become CSS classes, so the design system, not the editor, decides what "large padding" looks like.&lt;/p&gt;
&lt;p&gt;One Astro rule shapes the rest. A dynamic tag such as &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;&amp;lt;Component /&amp;gt;&lt;/span&gt; cannot carry a &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;client:*&lt;/span&gt; directive, because Astro must know at build time what to bundle. So each island is a small static wrapper in &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;src/components/islands/&lt;/span&gt;, registered under its content type, and &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;grep -rn "client:" src/components&lt;/span&gt; lists every component that ships JavaScript. In the demo that is one Svelte FAQ, while the React tiles, a Svelte price card and an &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;.astro&lt;/span&gt; Spotlight next to them render on the server and ship nothing.&lt;/p&gt;
&lt;p&gt;The practical consequence is the one that matters for a CMS team: the CMS content model no longer has to be tied to a single frontend framework. A component can be reimplemented in React, Svelte or &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;.astro&lt;/span&gt; while its content type and the Visual Builder composition stay the same, as long as the replacement keeps the props contract the registry passes it.&lt;/p&gt;
&lt;h2&gt;Live preview&lt;/h2&gt;
&lt;p&gt;Preview is one route, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;src/pages/preview.astro&lt;/span&gt;. The CMS opens it in an iframe with the content key, version, locale, context and a short-lived preview token; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;getPreviewContent()&lt;/span&gt; fetches the draft through Graph, and the page renders it through the same registry as the public site, so editors see exactly the components visitors get.&lt;/p&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/visual-builder-without-nextjs-part-1/cms13-editor-astro-preview.png" alt="Figure 3: The Landing experience in the CMS 13 editor, with the Astro preview route in the frame" /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;Figure 3. The Landing experience in CMS 13.5.0: the editor's properties on the left, the Astro preview route in the frame on the right. The hero, the Spotlight and the FAQ come from the same registry as the public site; the hero and the Spotlight render on the server, the FAQ (outlined by the editor's overlay) is the one Svelte island.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/visual-builder-without-nextjs-part-1/preview-loop-light.svg" alt="Figure 4: Live preview" /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;Figure 4. One route, the same registry as the public site, and a full reload after every save. Graph checks the token; the public site never sees drafts.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The CMS's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;communicationinjector.js&lt;/span&gt; connects the frame to the editor, and &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;createContentSavedListener()&lt;/span&gt; from the core entry point navigates the frame, after each save, to the preview URL the CMS sends with the event, which carries a new token. It is a plain &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;&amp;lt;script&amp;gt;&lt;/span&gt; with no React on the client, and the reload is a full one, since this page does not use Astro's client router. The route sends &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;private&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;no-store&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;noindex&lt;/span&gt; and a &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;frame-ancestors&lt;/span&gt; policy for the CMS origin. Graph checks the token: a bad or expired one gets a 401 from Graph, and the route answers 401 without detail and caches nothing. It does the same for any other failure to fetch the draft; the reason goes to the server log.&lt;/p&gt;
&lt;p&gt;On the CMS side, keep Graph's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;ContentVersionSynchronizationMode&lt;/span&gt; at its default, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;All&lt;/span&gt;: the preview reads the exact version the CMS asks for, and &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;PublishedOnly&lt;/span&gt; would leave drafts out of the index. The public site reads with the single key, which by design never sees drafts, and here the unpublished start page was visible with the app key and secret and invisible with the single key.&lt;/p&gt;
&lt;p&gt;One expectation to set with editors coming from CMS 12: headless sites have no inline editing on the page. They get property overlays that open the field in the editor, as Optimizely's CMS 13 FAQ notes.&lt;/p&gt;
&lt;h2&gt;Request isolation: one observation&lt;/h2&gt;
&lt;p&gt;Without a per-request scope preview still renders, so nothing looks wrong. With &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;adapter.run()&lt;/span&gt; removed from the middleware of the built site (the adapter itself still configured), a sequential test against the real CMS 13.5.0 and Graph showed the problem: after one preview request with a token issued by the CMS, the next public render of the same article carried that token in a rich-text image URL (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;?preview_token=&amp;hellip;&lt;/span&gt;). On this Astro server path, the SDK's request context, where &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;getPreviewContent()&lt;/span&gt; keeps the token, was not scoped per server request. That shows preview context reaching public output; it does not show that the token could be used to read drafts, and I did not try. A preview token does not belong in public HTML either way. With &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;adapter.run()&lt;/span&gt; back, the same sequence leaves no token in public HTML.&lt;/p&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/visual-builder-without-nextjs-part-1/request-isolation-light.svg" alt="Figure 5: Request isolation" /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;Figure 5. The same two requests with and without a per-request scope. A configured adapter read outside &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;run()&lt;/span&gt; falls back to one shared store, silently.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The SDK's request-context and framework-agnostic core guides prescribe exactly this: in frameworks other than React, configure an adapter and run each request inside it. Neither guide says what happens when a configured adapter is read outside &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;run()&lt;/span&gt;; only a doc comment in the adapter's source does. The adapter falls back to one process-wide store. No error, no warning. That is how a token set by one request reached another. On the SDK's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;main&lt;/span&gt; branch (CMS-58169, merged on 28 October, not yet on npm) media URLs no longer carry the token unless the preview opts in with &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;forceMediaToken&lt;/span&gt;, which narrows this symptom; the shared fallback is still there. In short, this is a reproduced failure mode of one integration: SDK 3.1.0-beta.0 in an Astro server, with the adapter configured but &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;run()&lt;/span&gt; missing. On its own, it does not establish a vulnerability in the SDK. With the documented request-scoping pattern, the token stayed out of public HTML in every test described here.&lt;/p&gt;
&lt;p&gt;In this project the fix is the guide's pattern: an &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;AsyncLocalStorage&lt;/span&gt;, configured once and entered for every request:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;// src/lib/context.ts
import { AsyncLocalStorage } from 'node:async_hooks';
import { configureAdapter, MemoryAdapter } from '@optimizely/cms-sdk/core';

export const adapter = new MemoryAdapter(new AsyncLocalStorage());
configureAdapter(adapter);
&lt;/pre&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { adapter } from '@/lib/context';

// Wraps the whole render, so every await inside components still sees this request's context.
export const onRequest = defineMiddleware((_context, next) =&amp;gt; adapter.run(() =&amp;gt; next()));
&lt;/pre&gt;
&lt;p&gt;A check guards it: 12 preview requests interleaved with 12 public requests to the freshly purged article put no &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;preview_token&lt;/span&gt; in public HTML, the preview HTML still carries it, and the check fails when &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;adapter.run()&lt;/span&gt; is removed. Part 2 looks at the mechanism in depth: where the SDK keeps this context, how that differs between runtimes, and how it behaves on DXP.&lt;/p&gt;
&lt;h2&gt;So, why Astro?&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Visual Builder:&lt;/strong&gt; framework-neutral planning lets Astro, React and Svelte components take part in the same composition. Only components marked as islands are hydrated in the browser.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Measured:&lt;/strong&gt; the article page ships no &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;; the landing experience loads 14.6 kB gzip (14,647 bytes), the Svelte runtime and the FAQ, only when the FAQ scrolls into view, plus 2.2 kB of inline island loader; the React client that &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;@astrojs/react&lt;/span&gt; builds (68.2 kB gzip) is loaded by no page.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Architecture:&lt;/strong&gt; React becomes optional. The content model survives a change of framework.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This does not make Astro a replacement for Next.js. I would still choose Next.js for a site that is mostly application (authenticated dashboards, heavy client state), for a team already invested in React Server Components, or for a component library that is React-only and has no reason to change.&lt;/p&gt;
&lt;h2&gt;Caveats&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Beta SDK.&lt;/strong&gt; The &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;/core&lt;/span&gt; entry point this design rests on, and the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;platform&lt;/span&gt; option, need 3.1.0-beta.0; the SDK itself has supported CMS 13 since 2.0 (May 2026), and I did not test 3.0.2 against PaaS. Pin the beta exactly and re-test at 3.1.0.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;PaaS headless is still evolving.&lt;/strong&gt; The FAQ for CMS 13.0.0 describes it as an area of ongoing development. In 3.1.0-beta.0, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;platform: 'paas'&lt;/span&gt; selects an adapter that builds the same queries as SaaS; that is true of this version, and later ones may differ.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CMS 13 only.&lt;/strong&gt; CMS 12 with Graph does not qualify.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Version mismatch to watch.&lt;/strong&gt; Astro 7.4.2 requires Node 22.23.4 or later. Below it the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;astro&lt;/span&gt; CLI (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;dev&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;build&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;preview&lt;/span&gt;) exits with an error; the built server does not check. Optimizely's front-end hosting documentation still lists Node 22.12.0 or later for Astro. That is a mismatch in the documentation, and I have not seen an actual incompatibility; part 2 checks the Node version the DXP build slot actually runs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Expect no inline editing&lt;/strong&gt; on headless sites; editors get overlays.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Not exercised here:&lt;/strong&gt; Opti ID, the DXP build slot, Redis and Cloudflare. Part 2 covers DXP, with the checks to run on Test1.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2&gt;The problems that cost the most&lt;/h2&gt;
&lt;p&gt;Three problems cost the most. Before publishing I reproduced each of them again, on 29 October, against the CMS 13 instance and the packages as published on npm:&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Problem&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Why&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;npm install @optimizely/cms-sdk&lt;/span&gt; installs 3.0.2; importing &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;@optimizely/cms-sdk/core&lt;/span&gt; fails (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;ERR_PACKAGE_PATH_NOT_EXPORTED&lt;/span&gt; in Node), and nothing in that version knows &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;platform: 'paas'&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;/core&lt;/span&gt; entry point and the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;platform&lt;/span&gt; option first ship in 3.1.0-beta.0, under the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;beta&lt;/span&gt; tag; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;latest&lt;/span&gt; is still 3.0.2&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;pin &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;@optimizely/cms-sdk&lt;/span&gt; and &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;@optimizely/cms-cli&lt;/span&gt; to exactly &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;3.1.0-beta.0&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;config push&lt;/span&gt; answers &lt;em&gt;The client credentials are invalid&lt;/em&gt;, although the CMS issues that client a token&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;without &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;OPTIMIZELY_CMS_API_URL&lt;/span&gt; or &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;--host&lt;/span&gt;, the CLI requests its token from the default SaaS gateway (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;api.cms.optimizely.com&lt;/span&gt;), which does not know a PaaS client; it never reads &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;OPTIMIZELY_CMS_URL&lt;/span&gt;. Documented in the CLI reference, but the error reads as a credentials problem&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;OPTIMIZELY_CMS_API_URL=https://&amp;lt;cms-host&amp;gt;&lt;/span&gt;; the CLI also loads &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;.env&lt;/span&gt; from the directory it runs in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;A TypeScript type with the key of a C# class is not saved: the CLI prints &lt;em&gt;✔ Configuration file uploaded&lt;/em&gt;, then &lt;em&gt;&amp;hellip;can only be updated by modifying this model&lt;/em&gt;, and exits with an error&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;types registered from .NET models (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;source: "_server"&lt;/span&gt;) are read-only through the REST API; the CMS answers 200 with an &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;errors&lt;/span&gt; list, and the CMS leaves those types out of &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;config pull&lt;/span&gt; unless you pass &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;--includeReadOnly&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;change the type in C#, or give the TypeScript type its own key; pull C# types only into a folder that &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;config push&lt;/span&gt; does not read&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The full list, with the CMS setup and every version I pinned, is in the repository. That includes the 403 on the start content, the start-page routing and the relative media URLs.&lt;/p&gt;
&lt;h2&gt;How I tested it&lt;/h2&gt;
&lt;p&gt;Every code excerpt is from the companion repository. It type-checks with &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;astro check&lt;/span&gt; (0 errors) and passes 10 unit tests for the cache provider of part 2. Its 28 checks (HTTP-level assertions in eight groups) ran against the real CMS 13.5.0 and Optimizely Graph, on a test article whose draft has an image in its rich text, with a preview token issued by the CMS REST API (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;GET /_cms/v1/content/{key}/versions/{version}/previews&lt;/span&gt;). Checks 1 to 6 ran through the repository's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;test/checks.sh&lt;/span&gt;. The repository's versions of checks 7 and 8 are written for local runs, so I ran those two with a separate script, whose logic is now in the repository as &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;test/real-checks-7-8.mjs&lt;/span&gt;: a real publish through the same REST API, after which Graph served the new version within about two seconds on this test instance (polled once a second; 1.5 to 1.6 s in three runs), and a scan of the client bundle for the real key values.&lt;/p&gt;
&lt;p&gt;The checks that matter for this part: the article page ships no &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;&amp;lt;script&amp;gt;&lt;/span&gt;, the landing page stays under 20 KiB of JavaScript, images load from the CMS, a bad preview token gets a 401 with &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;no-store&lt;/span&gt;, and no preview token leaks into public HTML under concurrency. That run also caught a bug. It was in my own code: on the stored-query path the SDK uses (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;?stored=true&lt;/span&gt;), Graph returns rich text's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;json&lt;/span&gt; as a string. Without that parameter it is an object, and behind Graph's CDN either shape can come back. The SDK's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;RichText&lt;/span&gt; accepts both shapes, but my media rewrite walked the raw value and missed images inside rich text until it parsed the string first.&lt;/p&gt;
&lt;p&gt;The instance is an Alloy-based CMS 13 on .NET 10 with ASP.NET Identity, with Astro added as a second, Headless application. On 13.4.2, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;config push&lt;/span&gt; created the model, the start content and the application through the CMS REST API, and the pages rendered from Graph; the 403, start-page routing and media URLs came from that run. After upgrading the same CMS to 13.5.0, the preview frame in Visual Builder showed the Astro page (figure 3), and the server log showed the frame reloading about a second after every save and again after publishing. Two things I have not yet confirmed in the editor: the &lt;em&gt;Square&lt;/em&gt; style switching the component, and click-to-field focus. What the run does not cover (Opti ID, DXP, Redis and Cloudflare) is part 2's job, measured on Test1.&lt;/p&gt;
&lt;h2&gt;Verdict&lt;/h2&gt;
&lt;p&gt;Yes: with the 3.1.0 beta, Astro can render Visual Builder content on CMS 13 PaaS, but it is not a drop-in replacement for the Next.js integration. The framework-neutral SDK provides the primitives; the application still has to provide the integration around them: one registry for its components, a preview route and its reload, routing that knows which application a path belongs to, and a request scope for the SDK's context. For the platform team, the useful feedback is about failure modes. The SDK's guides cover each of these pieces. What they don't show is where a missing piece fails silently: a configured &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;MemoryAdapter&lt;/span&gt; read outside &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;run()&lt;/span&gt; falls back to a shared store, the CLI without &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;OPTIMIZELY_CMS_API_URL&lt;/span&gt; asks the SaaS gateway for its token and reports a credentials error, and &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;config push&lt;/span&gt; prints &lt;em&gt;✔ Configuration file uploaded&lt;/em&gt; before the CMS's errors.&lt;/p&gt;
&lt;p&gt;The interesting part is not replacing Next.js. It is separating Optimizely's content composition model from the frontend framework, and knowing what you have to rebuild when you do.&lt;/p&gt;
&lt;p&gt;Part 2 takes a closer look at request isolation and asks the production question: can the same architecture survive DXP (Redis-backed ISR, Graph webhooks, Cloudflare and preview) without a redeploy for every publish?&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/pino-labs/astro-visual-builder-cms13"&gt;pino-labs/astro-visual-builder-cms13&lt;/a&gt;, the code, the checks and the CMS 13 setup notes.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Series:&lt;/em&gt; &lt;strong&gt;Part 1: What Astro Has to Rebuild on CMS 13 PaaS&lt;/strong&gt; &amp;middot; Part 2: Does It Survive DXP? (Redis-backed ISR, Graph webhooks, Cloudflare and preview at scale)&lt;/p&gt;
&lt;h2&gt;Sources&lt;/h2&gt;
&lt;p&gt;All links checked on 2026-10-29. The release notes moved from support.optimizely.com to docs.optimizely.com; the old addresses redirect.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/cms-13/release-notes"&gt;2026 Optimizely CMS 13 release notes&lt;/a&gt;: DXP support for Astro (31 Aug 2026), the JavaScript SDK on CMS 13, the CMS-57780 culture-specific data-loss fix in 13.4.1 and the restore of deleted values in 13.4.2 (26 Oct 2026), 13.5.0 (27 Oct 2026)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/cms-saas/release-notes/2026-cms-saas-release-notes"&gt;2026 CMS (SaaS) release notes&lt;/a&gt;: JavaScript SDK 3.0, the framework-neutral SDK core and the Astro sample (24 Sep 2026)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.npmjs.com/package/@optimizely/cms-sdk"&gt;@optimizely/cms-sdk on npm&lt;/a&gt;: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;latest&lt;/span&gt; 3.0.2, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;beta&lt;/span&gt; 3.1.0-beta.0 (20 Oct 2026); &lt;a href="https://github.com/episerver/content-js-sdk/releases"&gt;release notes on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/cms-13/docs/faqs-for-cms-13"&gt;FAQs for CMS 13&lt;/a&gt;: where content types live on PaaS headless, and no inline editing on headless sites&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/episerver/content-js-sdk"&gt;episerver/content-js-sdk&lt;/a&gt;: Astro sample, CMS PaaS guide, framework-agnostic core, request context, CLI reference and troubleshooting; on the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;main&lt;/span&gt; branch, changes CMS-58169 (media preview token opt-in, merged 28 Oct 2026) and CMS-57658 (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;config pull&lt;/span&gt; output flags outside a terminal, merged 26 Oct 2026)&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/cms-saas/docs/host-a-front-end-with-optimizely"&gt;Host a frontend with Optimizely&lt;/a&gt;: Node.js 22.12.0 or later for Astro on DXP front-end hosting&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.optimizely.com/dxp/docs/astro-isr-caching-and-optimizely-graph-webhooks"&gt;Astro ISR caching and Optimizely Graph webhooks&lt;/a&gt;: the DXP page for Astro, also listing Node.js 22.12.0 or later&lt;/li&gt;
&lt;li&gt;Prior work on SaaS CMS: Jacob Pretorius, &lt;a href="/link/ad0ab559cb97490491e9c4d448f232ca.aspx"&gt;Build a headless blog with Astro and Optimizely SaaS CMS Part 4 - SSR &amp;amp; Visual Builder Experiences&lt;/a&gt; (21 July 2025)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr /&gt;
&lt;p&gt;&lt;em&gt;Stack: Optimizely CMS 13.4.2 and 13.5.0 (Alloy-based, .NET 10, ASP.NET Identity) with Optimizely Graph, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;@optimizely/cms-sdk&lt;/span&gt; and &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;@optimizely/cms-cli&lt;/span&gt; 3.1.0-beta.0, Astro 7.4.2 on Node 24.19.0, React and Svelte components behind one registry. Optimizely, Astro, Next.js, React and Svelte are trademarks of their respective owners; this article is not affiliated with them.&lt;/em&gt;&lt;/p&gt;</summary></entry><entry><title>Visual Builder without Next.js, Part 1: What Astro Has to Rebuild on CMS 13 PaaS</title><link href="https://pino-labs.com/blog/visual-builder-without-nextjs-part-1-astro-cms-13-paas/" /><id>https://pino-labs.com/blog/visual-builder-without-nextjs-part-1-astro-cms-13-paas/</id><published>2026-10-29T00:00:00.0000000Z</published><updated>2026-10-29T12:30:35.0000000Z</updated><author><name>piotr.n@rockwool.com</name></author><category term="Blog post" /><summary type="html">A field report on the framework-neutral CMS SDK 3.1 beta: Visual Builder, the content model and live preview in Astro, on a real CMS 13.</summary></entry><entry><title>Experimentation When the Agent Makes the Decision</title><link href="https://pino-labs.com/blog/experimentation-when-the-agent-makes-the-decision/" /><id>https://pino-labs.com/blog/experimentation-when-the-agent-makes-the-decision/</id><published>2026-10-23T00:00:00.0000000Z</published><updated>2026-10-23T14:30:36.0000000Z</updated><author><name>piotr.n@rockwool.com</name></author><category term="Blog post" /><summary type="html">When an agent enters the loop, an A/B test loses its obvious unit, its obvious variant and its obvious decision-maker. WebMCP, WebKit's objection, a tool-poisoning paper and the Shopify and Commerce.com rollouts, read as a question for everyone selling experimentation and personalization, Optimizely included.</summary></entry><entry><title>Gated Content on Optimizely Graph, Part 2: Keeping Graph in Step with the CMS</title><link href="https://pino-labs.com/blog/gated-content-optimizely-graph-part-2-in-step/" /><id>https://pino-labs.com/blog/gated-content-optimizely-graph-part-2-in-step/</id><published>2026-10-06T00:00:00.0000000Z</published><updated>2026-10-06T21:30:51.0000000Z</updated><author><name>piotr.n@rockwool.com</name></author><category term="Blog post" /><summary type="html">A correct ACL in the CMS is not enough: Graph evaluates access against the state available to it. That state can lag behind the CMS, and every cache between Graph and the browser adds a window of its own.</summary></entry><entry><title>Gated Content on Optimizely Graph, Part 1: The ACL Is the Gate</title><link href="https://pino-labs.com/blog/gated-content-optimizely-graph-part-1-acl/" /><id>https://pino-labs.com/blog/gated-content-optimizely-graph-part-1-acl/</id><published>2026-09-29T00:00:00.0000000Z</published><updated>2026-09-29T18:30:57.0000000Z</updated><author><name>piotr.n@rockwool.com</name></author><category term="Blog post" /><summary type="html">Graph filters by role for you. The hard part is everything around that check: drafts, ACL propagation, races, and content next to the gate.</summary></entry><entry><title>What Your Optimizely DXP Is Serving to AI Crawlers - and the Three Levers You Actually Have</title><link href="https://pino-labs.com/blog/optimizely-dxp-ai-crawlers-three-levers/" /><id>https://pino-labs.com/blog/optimizely-dxp-ai-crawlers-three-levers/</id><published>2026-08-17T00:00:00.0000000Z</published><updated>2026-08-17T00:00:00.0000000Z</updated><author><name>piotr.n@rockwool.com</name></author><category term="Blog post" /><summary type="html">Unblocked bot traffic on Optimizely DXP is a billable page view, and the Cloudflare zone in front of your site is not yours to configure. Three levers remain: robots.txt, Optimizely Support, and bringing your own Cloudflare zone (Orange-to-Orange) for AI Crawl Control. Plus the trap in Cloudflare's July 2026 change, where blocking Training also blocks Googlebot, Applebot and BingBot.</summary></entry><entry><title>Content Variations in CMS 13, Part 3: Audiences vs Audiences</title><link href="https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/6/content-variations-in-cms-13-part-3-audiences-vs-audiences/" /><id>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/6/content-variations-in-cms-13-part-3-audiences-vs-audiences/</id><published>2026-06-14T17:46:48.0000000Z</published><updated>2026-06-14T17:46:48.0000000Z</updated><author><name>piotr.n@rockwool.com</name></author><category term="Blog post" /><summary type="html">&lt;blockquote style="margin: 24px 0; padding: 18px 20px; background: #f5f5f4; border: 1px solid #e7e5e4; border-radius: 6px;"&gt;
&lt;p&gt;&lt;strong&gt;Executive summary.&lt;/strong&gt; Part 2 left the experiment running against &lt;em&gt;Everyone&lt;/em&gt;. Real projects don't look like that. So this part wires those same CMS Content Variations to two rival audience engines and measures what each actually does. Every number below comes from a live CMS 13.1.0 + FX SDK instance. A targeted delivery served &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt; to 60 of 60 mobile visitors; at 100% allocation that is deterministic, so a single miss would be a bug rather than noise. The desktop experiment held its 33/33/34 split (&amp;chi;&amp;sup2; = 1.465, n = 300). The MVC head and the headless Next.js head agreed on the arm &lt;em&gt;and&lt;/em&gt; the rule for 20 of 20 visitor/device pairs, with zero coordination code. The same runs found the boundary. CMS Audiences evaluate only inside the CMS runtime: a personalized block vanished from Optimizely Graph entirely, invisible to every headless consumer, and nothing threw an error. If your requirement says &amp;ldquo;both heads&amp;rdquo;, the audience decision is made before anyone asks about features.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;&lt;em&gt;Parts 1 and 2 built the machine: Content Variations as the quiet hero of CMS 13, then Feature Experimentation as its stats engine, with one string, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;VariationKey == variation name&lt;/span&gt;, as the entire integration contract. This part answers the question that contract postpones: who decides who the visitor is? CMS 13 has an answer. FX has a different answer. Both are called Audiences now, and they are not the same thing. Everything below ran on CMS 13.1.0 with the C# SDK 4.3.0 and the JavaScript SDK 6.4.0. Every number is from those runs.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;In this part:&lt;/strong&gt; the Audiences naming collision &amp;middot; the three-layer opt-in (and the startup self-check it forced) &amp;middot; the five-attribute pipeline with native geolocation &amp;middot; targeted delivery semantics the docs bury &amp;middot; the coexistence proof and the Graph void &amp;middot; the day the headless head went dark &amp;middot; three QA override levels &amp;middot; a troubleshooting runbook &amp;middot; eight sharp edges ranked by blood &amp;middot; FAQ &amp;middot; glossary.&lt;/p&gt;
&lt;h2&gt;The machine at a glance&lt;/h2&gt;
&lt;p&gt;One picture before the prose. Both heads serve the same experiment from the same definitions. The only shared state is configuration-as-data:&lt;/p&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/machine-at-a-glance.svg" alt="Both heads read the same FX datafile and CMS Graph index. The MVC head runs Decide, loads the CMS variation and renders, with the CMS Audience filtering content-area items server-side. The Next.js head runs the same decision and renders via a Graph query, but the personalized items are absent from Graph." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;Both heads serve the same experiment from the same definitions; only the Graph path drops the personalized item. The CMS Audience layer has no representation on the headless route.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The CMS Audience layer simply has no representation on the Graph path. The rest of this article is the evidence for every arrow.&lt;/p&gt;
&lt;h2&gt;Two engines, one word: both of them are called Audiences now&lt;/h2&gt;
&lt;p&gt;Start with the vocabulary trap, because it will find you anyway. Visitor Groups have been rebranding to &lt;strong&gt;Audiences&lt;/strong&gt; since the CMS 12 admin redesign. CMS 13 finishes the job: the admin package describes itself as the &amp;ldquo;audiences management UI&amp;rdquo;, the docs say &amp;ldquo;an audience (formerly called a visitor group)&amp;rdquo;, and the old name survives mostly in role names and API types. Feature Experimentation has shipped &lt;strong&gt;Audiences&lt;/strong&gt; for years. Same word, two engines, and they answer the same question with opposite architectures:&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;&amp;nbsp;&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;CMS Audiences (formerly Visitor Groups)&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;FX Audiences&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Evaluated by&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;The CMS, server-side, during rendering&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Every SDK, in-process, at &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Decide()&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Evaluation input&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;IPrincipal&lt;/span&gt; + &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;HttpContext&lt;/span&gt;, full request, roles, visit history&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Only the attributes your code passed at context creation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Definition lives in&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;CMS admin (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;VisitorGroupAdmins&lt;/span&gt; role)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;FX dashboard, ships in the datafile&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Personalizes&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;A &lt;strong&gt;fragment&lt;/strong&gt;, one content-area item&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;A &lt;strong&gt;page version&lt;/strong&gt;, via the variation key contract from part 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Works headless&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;No, and the docs say so out loud&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Yes, the evaluation is a pure function, portable by construction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Measurement&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&amp;ldquo;Enable statistics&amp;rdquo; view counts, with no exposures, no conversions, no significance&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;The stats engine: exposures, conversions, significance&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The last two rows are the ones that matter. A CMS Audience can reach into everything the CMS knows about the request, and pays for it by existing only where the CMS renders. An FX Audience knows nothing you didn't tell it, and pays for &lt;em&gt;that&lt;/em&gt; with a pipeline you must build. But the function (datafile + attributes) &amp;rarr; bool runs identically in C#, in Node, in anything with an SDK. Keep that trade in view. Every decision below falls out of it.&lt;/p&gt;
&lt;p&gt;One governance row to add to part 2's who-does-what table: audience definitions get owners too. CMS Audiences belong to whoever holds &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;VisitorGroupAdmins&lt;/span&gt;. FX Audiences belong to the dashboard. Attribute &lt;em&gt;keys&lt;/em&gt;, though, are an API contract between the codebase and the dashboard: a developer renames &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;device&lt;/span&gt; and the marketer's audience quietly stops matching. Write them down somewhere both sides actually read.&lt;/p&gt;
&lt;h2&gt;Switching the engine on: in CMS 13, personalization is opt-in three layers deep&lt;/h2&gt;
&lt;p&gt;Part 2's sharp-edges list opened with a package that crashed CMS 13 at startup. This part's equivalent is gentler and stranger: in CMS 13, Visitor Groups are not in the box you already have. The &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;EPiServer.CMS&lt;/span&gt; metapackage ships &lt;strong&gt;neither the evaluation core nor the UI&lt;/strong&gt;. The install docs state the philosophy plainly: &amp;ldquo;every NuGet package your project references must have its services explicitly registered, or the application fails at startup.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The documented pair of calls is &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;services.AddVisitorGroupsMvc().AddVisitorGroupsUI()&lt;/span&gt;, which also adds the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;EPiServer.CMS.UI.VisitorGroups&lt;/span&gt; 13.1.0 package.&lt;/p&gt;
&lt;p&gt;I learned what each one carries the honest way, by registering less than that. &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;AddVisitorGroupsUI()&lt;/span&gt; alone boots clean. Then the Audiences screen throws &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Unable to resolve service for type 'IVisitorGroupCriterionRepository'&lt;/span&gt; the moment an editor clicks it: the screen's own API services, the ones that load criterion lists and persist rules, resolve against registrations the UI package does not carry. A UI that renders is not a UI that saves. Registering the two internal layers that error message points at fixes the admin screen. It also quietly skips the &lt;em&gt;rendering&lt;/em&gt; layer, so personalized blocks would render for everyone. That's the worst possible failure mode: a working configuration surface for a filter that doesn't run. &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;AddVisitorGroupsMvc()&lt;/span&gt; wraps all of it: repositories, the built-in criteria, the content-area rendering filter, and the &amp;ldquo;View as Audience&amp;rdquo; impersonation service.&lt;/p&gt;
&lt;p&gt;Notice what the docs promised and what actually happened. The promise: misregistration &lt;em&gt;fails at startup&lt;/em&gt;. The observation: it failed at click time, with a green boot log. So the demo project now resolves the services the Audiences API needs at startup, in Development, and logs every registered criterion: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;DeviceCriterion, DisplayChannelCriterion, &amp;hellip; TimeOfDayCriterion, &amp;hellip;&lt;/span&gt; (22 of them), making the documentation's promise true at exactly the layer where it broke. Twenty-two criteria, including &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;RoleCriterion&lt;/span&gt; and &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;UserProfileCriterion&lt;/span&gt; that the docs list doesn't mention, plus &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;DeviceCriterion&lt;/span&gt;, which is ours. The whole class fits on a slide, and it earns the line this demo is built around: one definition of &amp;ldquo;who you are&amp;rdquo;, two engines reading it.&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;[VisitorGroupCriterion(
    Category = "Technical",
    DisplayName = "Device class",
    Description = "Matches the visitor's device class (mobile / tablet / desktop) "
                + "using the same User-Agent heuristic that feeds the FX 'device' attribute.")]
public class DeviceCriterion(IVisitorAttributesProvider attributesProvider)
    : CriterionBase&amp;lt;DeviceCriterionModel&amp;gt;
{
    public override bool IsMatch(IPrincipal principal, HttpContext httpContext)
    {
        // Editors type the value by hand - trim + lowercase forgives "Mobile "
        // (the FX exact matcher would not; see sharp edge #1).
        var expected = Model.Device?.Trim().ToLowerInvariant();
        return !string.IsNullOrEmpty(expected)
               &amp;amp;&amp;amp; attributesProvider.GetAttributes().TryGetValue("device", out var device)
               &amp;amp;&amp;amp; Equals(device, expected);
    }
}&lt;/pre&gt;
&lt;p&gt;Constructor injection works (the built-in geographic criteria take &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;IClientGeolocationResolver&lt;/span&gt; the same way), &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;[VisitorGroupCriterion]&lt;/span&gt; registers the class through plugin scanning, and the model is &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;CriterionModelBase&lt;/span&gt; with one string property and a &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Copy() =&amp;gt; ShallowCopy()&lt;/span&gt;. The built-in &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;OSBrowserCriterion&lt;/span&gt; could classify devices without custom code. Ours exists for consistency with the FX attribute, not for extra capability.&lt;/p&gt;
&lt;h3&gt;Reference card: the CMS 13 personalization bill of materials&lt;/h3&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Call&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Package&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;What it actually registers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;AddVisitorGroupsMvc()&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;EPiServer.Cms.AspNetCore.Mvc&lt;/span&gt; (already referenced)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;The whole working engine: repositories and statistics, role infrastructure, the built-in criteria, the content-area &lt;strong&gt;rendering filter&lt;/strong&gt;, fragment handlers, View-as-Audience impersonation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;AddVisitorGroupsUI()&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;EPiServer.CMS.UI.VisitorGroups&lt;/span&gt; (add it; not in the metapackage)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;The Audiences management screen (protected module &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;VisitorGroups.zip&lt;/span&gt;) and its API controllers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;AddCmsClientGeolocation(o =&amp;gt; o.LocationHeader = &amp;hellip;)&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;EPiServer.Geolocation&lt;/span&gt; (already referenced)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Header-based geolocation: feeds the geographic criteria &lt;em&gt;and&lt;/em&gt; anything else consuming &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;IClientGeolocationResolver&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;[VisitorGroupCriterion]&lt;/span&gt; on your class&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;your project&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Custom criteria via plugin scanning; explicit &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;AddVisitorGroupsCriterion&amp;lt;T&amp;gt;()&lt;/span&gt; only if you disable scanning&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;And the criteria catalog as measured on 13.1.0, the self-check log, deduplicated and grouped, because no docs page currently lists all of them:&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Group&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Criteria&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Behavior&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;NumberOfVisits, ViewedPages, ViewedCategories, Download, TimeOnSite, Event&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Arrival&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Referrer, SearchWordReferrer, StartUrl, QueryString&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Time&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;TimeOfDay, TimePeriod&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Place &amp;amp; client&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;GeographicLocation, GeographicCoordinate, IPRange, OSBrowser, DisplayChannel, SelectedLanguage&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Identity&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Role, UserProfile, VisitorGroupMembership&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Custom (this demo)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;DeviceCriterion&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Two of those built-ins, configured: a time-of-day window and a country match, both reading request and CMS state the FX engine never sees.&lt;/p&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/vg-criterion-timeofday.png" alt="Built-in Time of Day criterion on an Office hours CMS audience: 08:00-16:00, Monday to Friday." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;Built-in Time of Day criterion on an &amp;ldquo;Office hours&amp;rdquo; CMS audience: 08:00&amp;ndash;16:00, Monday to Friday.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/vg-criterion-geo.png" alt="Built-in Geographic Location criterion on a Visitors from PL CMS audience: Europe / Poland." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;Built-in Geographic Location criterion on a &amp;ldquo;Visitors from PL&amp;rdquo; CMS audience: Europe / Poland.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;The attribute pipeline: FX only knows what you tell it&lt;/h2&gt;
&lt;p&gt;The part-2 provider sent two attributes derived from the request: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;device&lt;/span&gt; from the User-Agent, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;location&lt;/span&gt; from geo headers. Part 3 grows it to five, and the growth is where the lessons are:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;return new Dictionary&amp;lt;string, object?&amp;gt;
{
    ["device"]    = ResolveDevice(request),          // "mobile" | "tablet" | "desktop"
    ["location"]  = ResolveLocation(context),        // "PL", "SE", ... | "unknown"
    ["logged_in"] = context?.User.Identity?.IsAuthenticated == true,   // a real bool
    ["cms_role"]  = ResolveCmsRole(context?.User),   // "admin" | "editor" | "none"
    ["consent"]   = HasConsent(request)              // a real bool, from a cookie
};&lt;/pre&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/fx-attributes.png" alt="FX dashboard, Settings to Audiences to Attributes: the five registered attribute keys." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;The five registered attribute keys (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;cms_role&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;consent&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;device&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;location&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;logged_in&lt;/span&gt;), the code-to-dashboard contract.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Three rules. All three carried more weight than I expected when I wrote them down. (Throughout this article, &amp;ldquo;the panel&amp;rdquo; means part 2's instrument set. The demo page renders a &lt;em&gt;variation pill&lt;/em&gt; carrying the served arm plus a &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;data-rule&lt;/span&gt; attribute naming the rule that served it, and a Development-only &lt;em&gt;diagnostics panel&lt;/em&gt; showing the visitor ID, the attribute dictionary and the SDK's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Decide&lt;/span&gt; reasons. Every measurement below reads off those two surfaces.)&lt;/p&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/demo-diagnostics.png" alt="The variation pill and the FX decision diagnostics." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;&amp;ldquo;The panel&amp;rdquo;: the variation pill and the FX decision diagnostics: visitor ID, served variation, the attribute dictionary, and the SDK INCLUDE_REASONS trace.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;&lt;strong&gt;Types are the contract.&lt;/strong&gt; Attributes are untyped in the dashboard. The value you &lt;em&gt;send&lt;/em&gt; decides which comparisons can match. Send the string &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;"true"&lt;/span&gt; against a boolean condition and the condition is skipped: evaluated to UNKNOWN, audience false, visitor falls through, nobody logs an error at default verbosity. The booleans above are real booleans for exactly that reason. (Sharp edge #3 has the measurement.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Absent and false are different inputs.&lt;/strong&gt; An anonymous visitor on the MVC head sends &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;logged_in = false&lt;/span&gt;. If the headless head simply omitted the key, an audience targeting &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;logged_in is false&lt;/span&gt; would match on one head and skip on the other. Same visitor, same flag, different arms, and you would hunt the bug in the hashing where it isn't. The Next.js head therefore sends the constants its reality justifies: its visitors are, truthfully, never logged in.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One request, one context, attributes enter once.&lt;/strong&gt; The decision service is request-scoped and builds the SDK user context a single time. The banner flag, the experiment and the conversion all agree on who the visitor is. Change an attribute mid-request and nothing happens. That's by design, and worth a comment in the code so nobody &amp;ldquo;fixes&amp;rdquo; it.&lt;/p&gt;
&lt;h3&gt;Geolocation: one header now feeds both engines&lt;/h3&gt;
&lt;p&gt;The hand-rolled four-header geo parser from part 2 is gone. CMS 13 ships native client geolocation: country from a single configured CDN header, no local IP database. One line wires it: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;services.AddCmsClientGeolocation(o =&amp;gt; o.LocationHeader = "CF-IPCountry")&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;The same &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;IClientGeolocationResolver&lt;/span&gt; now answers two callers: our FX &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;location&lt;/span&gt; attribute and the built-in geographic criterion of CMS Audiences. One configuration line, two engines. That also means one spoofed header fools both at once. &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;curl -H "CF-IPCountry: SE"&lt;/span&gt; and the panel reads &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;location=SE&lt;/span&gt;. The part-2 caveat about attacker-supplied input didn't go away. It doubled its blast radius. And a detail measured the hard way: the resolver wants an &lt;strong&gt;uppercase&lt;/strong&gt; ISO code. &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;PL&lt;/span&gt; resolves; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;pl&lt;/span&gt; resolves to nothing. Cloudflare sends uppercase, so production works. But any homegrown proxy that normalizes headers to lowercase turns your geographic targeting off, &lt;em&gt;both engines' worth of it&lt;/em&gt;, with no warning, no log line, no sound. The diagnostics panel reading &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;location=unknown&lt;/span&gt; while the header is plainly there is the only tell.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The consent attribute is not an attribute.&lt;/strong&gt; Or rather: it is targetable like any other, but its real job is a switch. Impression and conversion events carry the visitor ID and the attribute dictionary to Optimizely's backend. That payload is precisely the thing a non-consenting visitor declined. So when &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;consent&lt;/span&gt; is false, the service calls &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Decide&lt;/span&gt; with &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;DISABLE_DECISION_EVENT&lt;/span&gt; and turns &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Track&lt;/span&gt; into a logged no-op. Flags keep working, because the datafile evaluation is local, but nothing leaves the building. Consent isn't an audience attribute. It's an event-egress switch. (The demo toggles it with a cookie; a real implementation wires it to your CMP. And the conversion button now reports &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;tracked: false&lt;/span&gt; instead of celebrating an event that never left. A demo that lies to you is worse than no demo.) One boundary disclosed rather than implied: the visitor-ID cookie itself is set, and bucketing happens, before any consent. This build treats the identifier as functional state and gates only what leaves the building. Your CMP, or your DPO, may read that line differently. That conversation belongs in your project rather than an SDK option.&lt;/p&gt;
&lt;p&gt;One honest note before moving on: FX has no server-side bot filtering. A crawler's User-Agent contains no &amp;ldquo;Mobi&amp;rdquo;, so every bot enters your experiment as a desktop visitor and dilutes whatever it touches. Either gate &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Decide&lt;/span&gt; on known-bot UAs, or accept the noise with your eyes open.&lt;/p&gt;
&lt;h2&gt;Targeted Delivery: personalization without the dice&lt;/h2&gt;
&lt;p&gt;The plan was seductively simple: put a targeted rule &lt;em&gt;above&lt;/em&gt; the everyone-else A/B test. Mobile gets the personalized arm, everyone else keeps experimenting. The dashboard said no, and the way it says no is the finding. The ruleset isn't one sortable list. It's three fixed sections whose headings literally narrate the evaluation order:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;First, match experiment rule&lt;/strong&gt; &amp;rarr; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;a_b_test&lt;/span&gt; (A/B, audience: Non-mobile visitors)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Then, the following rules will be evaluated for all visitors&lt;/strong&gt; &amp;rarr; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;mobile_delivery&lt;/span&gt; (deliver &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt;, 100%)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Then, for everyone else&lt;/strong&gt; &amp;rarr; Off&lt;/li&gt;
&lt;/ol&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/ruleset-layered.png" alt="The ruleset three fixed sections: experiment first, then deliveries for all visitors, then everyone-else." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;The ruleset's three fixed sections: experiment first, then deliveries for all visitors, then everyone-else. No shared list to drag a delivery above an experiment.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/rule-targeted.png" alt="The mobile_delivery rule: Targeted Delivery, audience Mobile visitors, 100%, deliver variant_a." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;The &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;mobile_delivery&lt;/span&gt; rule: Targeted Delivery, audience Mobile visitors, 100%, deliver &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt;.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/fx-audience-mobile.png" alt="The Mobile visitors FX audience: device equals mobile." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;The Mobile visitors FX audience: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;device&lt;/span&gt; equals &amp;ldquo;mobile&amp;rdquo;, with all five attribute keys in the browser on the right.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/fx-audience-codemode.png" alt="The same audience in Code Mode: match_type exact." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;The same audience in Code Mode: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;match_type&lt;/span&gt; &amp;ldquo;exact&amp;rdquo;. The datafile the SDKs evaluate spells the field &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;match&lt;/span&gt; (sharp edge #1).&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/fx-audience-nonmobile.png" alt="The Non-mobile visitors audience: device equals desktop OR tablet." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;The Non-mobile visitors audience used to carve the experiment: a positive list, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;device&lt;/span&gt; equals desktop OR tablet, so an unknown device matches nothing and lands on master.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;FX evaluates experiments before deliveries. Always. The UI encodes the law in its page structure rather than enforcing it with an error. So carving mobile out of the experiment happens in the &lt;em&gt;audience&lt;/em&gt;, not in rule order. The A/B rule's audience changed from &lt;em&gt;Everyone&lt;/em&gt; to &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Non-mobile visitors&lt;/span&gt;: a deliberately positive list (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;device = desktop OR tablet&lt;/span&gt;) rather than a negation. That way a visitor with a missing or mangled &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;device&lt;/span&gt; attribute matches &lt;em&gt;nothing&lt;/em&gt; and lands on master, which is the honest outcome for &amp;ldquo;we don't know who this is&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;Two semantics from the docs that almost nobody quotes, both load-bearing:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;An &lt;strong&gt;experiment&lt;/strong&gt; whose audience matches but whose traffic allocation misses &lt;em&gt;rolls down&lt;/em&gt; to the next rule. A &lt;strong&gt;delivery&lt;/strong&gt; in the same situation &lt;em&gt;jumps straight to everyone-else&lt;/em&gt;, skipping any deliveries below it.&lt;/li&gt;
&lt;li&gt;Deliveries produce &lt;strong&gt;no Results page&lt;/strong&gt;: &amp;ldquo;No decision events show up on the results page.&amp;rdquo; A delivery is deployment, not measurement. Our mobile visitors get their personalization and leave the experiment's bookkeeping entirely. The A/B sample quietly becomes a desktop-and-tablet sample. Write that sentence in your analysis doc before someone asks why the experiment's traffic dropped.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And one behavioral nuance that will eventually puzzle a stakeholder: bucketing is sticky, attributes are not. The same visitor who taps &amp;ldquo;Request desktop site&amp;rdquo; in mobile Chrome flips &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;device&lt;/span&gt; mid-cookie, exits the delivery, and enters the experiment. Same visitor ID, different arm, both serves correct. Attribute-driven targeting re-evaluates every request. Only the hash is forever.&lt;/p&gt;
&lt;p&gt;Configuration footnote from the audience builder: the dashboard's Code Mode validates conditions with the key &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;match_type&lt;/span&gt;, while the datafile the SDKs download spells the very same field &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;match&lt;/span&gt;. Two serializations of one concept. Harmless until you copy a condition from the datafile into Code Mode and the validator rejects what the SDK just evaluated. The SDK's own log line later in this article shows the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;match&lt;/span&gt; spelling in the wild.&lt;/p&gt;
&lt;h2&gt;Can both engines coexist on one page? Yes, at different layers, measurably&lt;/h2&gt;
&lt;p&gt;This is the part people actually google, so here is the layered setup, built and measured. FX picks the &lt;em&gt;page version&lt;/em&gt; (mobile &amp;rarr; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt; via the delivery), and inside that version one block carries a CMS Audience (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Mobile visitors (CMS)&lt;/span&gt;, our &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;DeviceCriterion&lt;/span&gt;). Two engines, two layers, same page.&lt;/p&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/vg-audiences-list.png" alt="CMS Audiences admin: Mobile visitors (CMS), Office hours, Visitors from PL." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;CMS Audiences admin: Mobile visitors (CMS), Office hours, Visitors from PL.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/vg-admin.png" alt="The Mobile visitors (CMS) audience on the custom Device class criterion." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;The Mobile visitors (CMS) audience on the custom Device class criterion (value &amp;ldquo;mobile&amp;rdquo;), statistics enabled.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/vg-personalize.png" alt="Assigning the audience to a content-area block inside variant_a in the CMS editor." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;Assigning the audience to a content-area block inside &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt; in the CMS editor, with the rendered variant on the right.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The measurement needed care, because FX variation noise would drown the block signal. Take one visitor ID that desktop-buckets into &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt; &lt;em&gt;through the experiment&lt;/em&gt;, then request the page twice: same visitor, same page version, only the User-Agent differs.&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Probe (same visitor, same &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt;)&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Rule that served it&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Content area&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Desktop&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;a_b_test&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;empty, block filtered out&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Mobile&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;mobile_delivery&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&amp;ldquo;Mobile Quiet Hero&amp;rdquo; renders&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Pure visitor-group effect, isolated from FX. The coexistence answer: &lt;strong&gt;no conflict, different layers&lt;/strong&gt;. The FX decision happens in the controller before rendering. The CMS Audience filters content-area items during rendering. The pitfall isn't a conflict, it's unreachability: personalize a block for mobile in the &lt;em&gt;master&lt;/em&gt; version and no one will ever see it, because under this ruleset mobile never receives master. The FX rule one layer up decides which content area exists at all.&lt;/p&gt;
&lt;p&gt;There's a sour bonus measurement. An earlier build of this demo, the one registered from decompiled internals instead of the documented &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;AddVisitorGroupsMvc()&lt;/span&gt;, served that personalized block to the desktop probe too. Same content, same audiences, one missing rendering filter, zero errors. Keep that pair of runs. It's the cleanest argument I own for &amp;ldquo;configure from the docs, not from the decompiler&amp;rdquo;.&lt;/p&gt;
&lt;h3&gt;What does Content Graph actually do with a personalized block?&lt;/h3&gt;
&lt;p&gt;The same page travels to the Next.js head through Optimizely Graph. The personalized item doesn't:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;variant_a  &amp;rarr;  MainContentArea: []        (the block the MVC head demonstrably renders)
original   &amp;rarr;  MainContentArea: []
control    &amp;rarr;  MinimalPage.HeroArea: [{ "Heading": "EN Heading" }]   // non-personalized area expands fine&lt;/pre&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/graph-void.png" alt="Optimizely Graph: all three DemoPage arms are in the index with distinct DemoTitle, yet every MainContentArea is empty." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;Optimizely Graph: all three DemoPage arms (variant_a, variant_b, master) are in the index with distinct DemoTitle, yet every MainContentArea is &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;[]&lt;/span&gt;. The page variation survives; the personalized content-area relation does not.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Not a leak. A void. The VG-personalized item vanishes from the index for &lt;em&gt;every&lt;/em&gt; Graph consumer, including the mobile visitors who would have matched. No API error, no warning in the sync job, nothing for the front-end to even detect. The shape of the void is worth a second look: the &lt;em&gt;block itself&lt;/em&gt; is still in the index, four &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;QuietHeroBlock&lt;/span&gt; documents, each with its own identity and content, but the page's content-area &lt;strong&gt;relation&lt;/strong&gt; to it is gone. The explanation consistent with every measurement is that indexing evaluates the page without a visitor to personalize for, and an audience-gated item has no honest answer to &amp;ldquo;should this exist?&amp;rdquo;, so it doesn't. Whatever the internals, the contract you can rely on is the measured one: personalization severs the composition, not the content. The docs say it without decoration: &amp;ldquo;audiences do not work on headless sites.&amp;rdquo; The measurement above is what that sentence costs in practice.&lt;/p&gt;
&lt;p&gt;So what do you actually do when the roadmap says headless? There are three shapes, in order of preference. First, move that personalization up a layer to FX Audiences and Content Variations. That is this article's whole thesis, and it needs nothing new. Second, take the CMS 13.1.0 Graph Conventions API and customize indexing so the head receives the items plus enough metadata to filter client-side. Workable, but now &lt;em&gt;you&lt;/em&gt; own the audience semantics on every head. Third, stand up a membership endpoint on the CMS that the head consults per request. It works, and it is exactly the coordination code this series exists to avoid. There is no fourth option where the block just shows up.&lt;/p&gt;
&lt;h3&gt;The decision table&lt;/h3&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;You need&amp;hellip;&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Engine&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;The same personalization on every head, today and after the next re-platform&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;FX Audiences + Content Variations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Criteria that read CMS state: roles, visit history, time of day&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;CMS Audiences&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Measurement: exposures, conversions, significance&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;FX (deliveries excluded, they don't report)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Kill switch without a deploy&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;FX (latency = datafile propagation; measured at ~75 s here)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Fragment-level personalization, CMS-rendered site, no measurement need&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;CMS Audiences&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Zero marginal license cost&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;CMS Audiences (in the CMS license; FX is usage-billed, though deliveries fire no impressions, only experiment bucketing does)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Per-request evaluation cost approaching zero&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;FX (pure in-memory function; VG criteria may do I/O per content-area item)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Ownership by content editors: instant, local, visual&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;CMS Audiences (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;VisitorGroupAdmins&lt;/span&gt;; no deploy, no datafile, changes land on publish)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Ownership by product managers and analysts&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;FX Audiences, with attribute-key discipline as the price: keys are a code-to-dashboard contract, and once created an attribute stays in the datafile permanently&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;A word of architectural restraint: a personalized block inside an experiment variation multiplies your test matrix: arms &amp;times; audiences &amp;times; consent states. The demo does it to prove the layering. Production teams should default to &lt;strong&gt;one personalization axis per page&lt;/strong&gt;, and budget QA explicitly before crossing them.&lt;/p&gt;
&lt;p&gt;ODP deserves its one paragraph: when the attributes should come from customer &lt;em&gt;data&lt;/em&gt; rather than the current request, Real-Time Segments for Feature Experimentation syncs ODP audiences into the same rule slots. Segments stay out of the datafile, qualification typically lands under thirty seconds, and Optimizely's own docs concede that when you need 100% accuracy you should fall back to plain custom attributes. Different source, same predicate machinery. That's the whole story here. This demo has no ODP account on purpose.&lt;/p&gt;
&lt;h2&gt;From prose to proof: the day the headless head went dark&lt;/h2&gt;
&lt;p&gt;Here is the embarrassing measurement first, because it earns the rest of the section. The moment the A/B rule's audience changed from &lt;em&gt;Everyone&lt;/em&gt; to &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Non-mobile visitors&lt;/span&gt;, the Next.js head, which part 2 proudly demonstrated holding a perfect 33/33/34, went one hundred percent dark:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;default (master)      300  (100.00 %)
FAILED: "300 request(s) were served the master page (no FX decision) - check
that the rule is Running, Traffic Allocation is 100% and the SDK key matches
the rule's environment."&lt;/pre&gt;
&lt;p&gt;Rule running, allocation 100%, SDK key correct. The test's own diagnostic, written by me in part 2, blames everything except the actual cause: the head's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;decide()&lt;/span&gt; sent &lt;strong&gt;no attributes&lt;/strong&gt;, so the visitor matched neither audience and fell through to everyone-else-off. No errors, green dashboards, a misleading failure message, and a head silently serving master to every visitor from the minute the audience shipped. Audiences don't add a feature to your architecture. They add a &lt;em&gt;requirement&lt;/em&gt; to every head you run.&lt;/p&gt;
&lt;p&gt;The fix is a mirror: a &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;buildAttributes()&lt;/span&gt; on the Next side that reproduces the .NET pipeline byte for byte. The parity contract lives in one file with a comment that says exactly that: if the heuristics drift, parity dies at the attribute layer while everyone debugs the hash. The contract itself fits in a table, which is how it should be reviewed:&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Attribute&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;The rule both heads obey&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;device&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Substring tests, case-insensitive: iPad/Tablet &amp;rarr; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;tablet&lt;/span&gt;, else Mobi &amp;rarr; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;mobile&lt;/span&gt;, else &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;desktop&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;location&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Header path is strict: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;^[A-Z]{2}$&lt;/span&gt; and &amp;ne; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;XX&lt;/span&gt;, &lt;strong&gt;no case folding&lt;/strong&gt; (mirrors the measured CMS resolver); the Accept-Language fallback &lt;em&gt;does&lt;/em&gt; fold case (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;pl-PL&lt;/span&gt; &amp;rarr; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;PL&lt;/span&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;logged_in&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;cms_role&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Real types, truthful constants on the auth-less head (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;false&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;"none"&lt;/span&gt;): absent and false are different inputs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;consent&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;bool.TryParse&lt;/span&gt; semantics: case-insensitive &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;true&lt;/span&gt;, anything else false&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Upgrading the JS SDK to v6 along the way produced one trap worth its own line. v6 is explicit-opt-in across the board: polling config manager, logger, and ODP all opt-in. So is the &lt;strong&gt;event processor&lt;/strong&gt;: omit &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;createBatchEventProcessor()&lt;/span&gt; and decisions keep flowing while the SDK dispatches &lt;em&gt;no events at all&lt;/em&gt;. Served-but-unmeasured, the exact failure shape this series keeps finding, now available at the initialization layer. (The same modularity quietly fixed an old log nag: no ODP manager, no &amp;ldquo;ODP is not integrated&amp;rdquo; warnings.)&lt;/p&gt;
&lt;p&gt;Two Next.js-specific notes earned their scars. The SDK instance is cached on &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;globalThis&lt;/span&gt;, the same trick Prisma clients use, because &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;next dev&lt;/span&gt;'s Fast Refresh re-evaluates modules. A module-level singleton would leak a fresh polling manager and event processor on every save, and your datafile CDN would meet them all.&lt;/p&gt;
&lt;p&gt;And one asymmetry to disclose rather than hide: the C# head flushes its event queue on shutdown because the DI container disposes the client (part 2's contract). A Node process has no such container. The batch rides &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;flushInterval&lt;/span&gt;, and what happens at the end of a process's life depends entirely on what kind of life it had:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Long-running Node&lt;/strong&gt; (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;next start&lt;/span&gt;, a container): safe. The queue drains on the interval, and a tail lost to a SIGTERM is a rounding error.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Serverless / edge&lt;/strong&gt;: risky. The platform can freeze the instance the moment the response returns, and a consented conversion dies in the buffer as a zombie event.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The fix where it matters&lt;/strong&gt;: shrink the batch to the point of synchronicity, or &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;await client.close()&lt;/span&gt; before the invocation ends, and accept the latency as the price of the data.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Pretending the queue always drains would be exactly the kind of silence this series hunts.&lt;/p&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/next-head.png" alt="The Next.js headless head on a mobile viewport serving variant_a via mobile_delivery." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;The Next.js headless head on a mobile viewport: the same FX decision serves &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt; via &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;mobile_delivery&lt;/span&gt;, and the variation's title arrives through Optimizely Graph, with zero coordination with the MVC head.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Then the green numbers, all in one evening:&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Check&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Mobile, 60 fresh visitors (hard assertion: a delivery rolls no dice)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;60/60 &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt; via &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;mobile_delivery&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Desktop distribution, n = 300, against the Next head&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;103 / 105 / 92, &lt;strong&gt;&amp;chi;&amp;sup2; = 0.985&lt;/strong&gt; (critical 13.816 at &amp;alpha; = 0.001, df = 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Same regression against the MVC head&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;99 / 109 / 92, &amp;chi;&amp;sup2; = 1.465&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Cross-head parity: 4 fixed + 6 random visitors &amp;times; desktop and mobile&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;strong&gt;arm AND rule identical on both heads, 20/20 pairs&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/next-distribution.svg" alt="The desktop chi-square run against the headless Next.js head: 103 / 105 / 92 over n = 300, chi-square 0.985." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;The desktop chi-square run against the headless Next.js head: 103 / 105 / 92 over n = 300, &amp;chi;&amp;sup2; = 0.985, well under the 13.816 critical value. Every decision came from Optimizely FX in the Node SDK, zero coordination with the MVC head.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The parity test asserts the rule key, not just the arm. &amp;ldquo;mobile got &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt;&amp;rdquo; and &amp;ldquo;mobile happened to be bucketed into &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt;&amp;rdquo; are different claims, and only &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;data-rule&lt;/span&gt; in the markup separates them. MurmurHash plus deterministic audience evaluation. Still zero coordination code.&lt;/p&gt;
&lt;p&gt;The arithmetic deserves to be shown once, not just asserted. For the MVC regression (99 / 109 / 92 over n = 300, expected &amp;asymp; 100 per arm):&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;&amp;chi;&amp;sup2; = (99&amp;minus;100)&amp;sup2;/100 + (109&amp;minus;100)&amp;sup2;/100 + (92&amp;minus;100)&amp;sup2;/100
   = 0.01 + 0.81 + 0.64 &amp;asymp; 1.46&lt;/pre&gt;
&lt;p&gt;(The suite reports 1.465 because it tests against the configured 33.33/33.33/33.34 rather than exact thirds.) With df = k &amp;minus; 1 = 2 and &amp;alpha; = 0.001, the critical value is 13.816, and 1.465 ≪ 13.816, so the null hypothesis (&amp;ldquo;the split matches the configuration&amp;rdquo;) survives comfortably. In words: after carving the mobile segment out with an audience, the hash shows no distributional anomaly in what remains. The same computation on the Next head's 103 / 105 / 92 gives 0.985, comfortably inside the spread a fair split throws at n = 300.&lt;/p&gt;
&lt;p&gt;Threats to validity, disclosed as ever: the chi-square run verifies the bucketing distribution, not content delivery (part 2's caveat stands). The mobile check is exhaustive rather than statistical, because a 100% delivery is deterministic: a single counterexample falsifies it, no &amp;alpha; required. And every test here runs without a consent cookie, which after the egress gate means &lt;strong&gt;the suite sends zero impressions to FX&lt;/strong&gt;. Three hundred requests of load testing used to pollute Results. Now the same suite is invisible to it. That started as a privacy control and turned out to be test hygiene.&lt;/p&gt;
&lt;h2&gt;QA: three override levels, three owners&lt;/h2&gt;
&lt;p&gt;&amp;ldquo;How does QA see the variant for a segment they don't belong to?&amp;rdquo; That is the question audiences force on every test plan. Three answers, escalating by who owns them:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;New visitor&lt;/strong&gt; (the cookie-reset button, part 2): owned by anyone with a browser. Re-rolls the dice but can't cross an audience boundary. The button also demonstrates something subtler: rotating the visitor ID re-buckets every &lt;em&gt;experiment&lt;/em&gt;, leaves a &lt;em&gt;delivery&lt;/em&gt; unmoved (its audience is deterministic in the attributes, not the ID), and the CMS Audience doesn't even notice; its criteria never saw your visitor ID in the first place. One page, three different notions of who &amp;ldquo;you&amp;rdquo; are. QA plans that conflate them chase ghosts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Allowlist&lt;/strong&gt; (dashboard, per rule, up to fifty IDs): owned by the marketer, pins a visitor ID to an arm. Measured here: an allowlisted ID came back &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_b&lt;/span&gt; with the SDK reason &amp;ldquo;is forced in variation&amp;rdquo;. Caveat: allowlisted traffic still fires impressions, so your QA session pollutes Results.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;?fx_force=demo_ab_test:variant_b&lt;/span&gt;&lt;/strong&gt; (this part): owned by the developer, a Development-only query parameter mapped to the SDK's forced-decision API. It bypasses audiences &lt;em&gt;and&lt;/em&gt; allocation (forced mobile, forced desktop, doesn't matter), and, deliberately unlike the allowlist, a forced request also suppresses its decision events. QA that leaves no fingerprints on the data it's there to protect.&lt;/li&gt;
&lt;/ol&gt;
&lt;figure style="margin: 24px auto; text-align: center; max-width: 800px;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-3/ab-rule-allowlist.png" alt="The A/B Test rule: audience Non-mobile visitors, 100%, the split, and the allowlist pinning a QA visitor ID to variant_b." /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;&lt;em&gt;The A/B Test rule: audience Non-mobile visitors, 100%, the 33.33 / 33.33 / 33.34 split, and the allowlist pinning a QA visitor ID to &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_b&lt;/span&gt;.&lt;/em&gt;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The whole mechanism is one small source behind one small interface, so the SDK types stay where part 2 put them:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;var forced = forcedDecisionSource.GetForcedDecisions();   // [] outside Development
foreach (var (flagKey, variationKey) in forced)
{
    userContext.SetForcedDecision(
        new OptimizelyDecisionContext(flagKey),
        new OptimizelyForcedDecision(variationKey));
}
_eventsAllowed &amp;amp;= forced.Count == 0;   // QA traffic never reaches Results&lt;/pre&gt;
&lt;p&gt;While we're holding the decide call open, the full option set gets a reference table (part 2 named these in prose):&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Decide option&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;What it does&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Where this series uses it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;INCLUDE_REASONS&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Returns the evaluation trace: which rule matched, why audiences failed&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Every call; feeds the diagnostics panel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;DISABLE_DECISION_EVENT&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Evaluates without sending an impression&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;No-consent requests; forced QA requests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;ENABLED_FLAGS_ONLY&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Filters &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;DecideAll&lt;/span&gt; to enabled flags&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;IGNORE_USER_PROFILE_SERVICE&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Skips UPS stickiness for this call&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;n/a (this demo runs without UPS)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;EXCLUDE_VARIABLES&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Omits variable payloads for cheaper decisions&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The CMS side has its own counterpart, &lt;strong&gt;View as Audience&lt;/strong&gt; in the editor, with one boundary note: it previews visitor groups, not FX arms, and the docs scope it to CMS-rendered sites only. Pair the two in your test plan. Neither substitutes for the other.&lt;/p&gt;
&lt;p&gt;Forced decisions don't persist. They clear with the user context, which in this architecture means they last exactly one request. That's not a limitation. Per-request is the only scope that can't leak into someone's real session.&lt;/p&gt;
&lt;h2&gt;Runbook: &amp;ldquo;the page always serves master&amp;rdquo;, audience edition&lt;/h2&gt;
&lt;p&gt;Part 2 ended with a checklist for the all-master symptom. Audiences roughly double it. The developer's mental map first: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;data-rule&lt;/span&gt; on the pill is the fork in the road.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;data-rule is empty&lt;/strong&gt;, no decision happened at all: SDK key, datafile or flag off (part 2's checklist).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data-rule = default-rollout-&amp;hellip;&lt;/strong&gt;, &amp;ldquo;everyone else&amp;rdquo; fired, so no audience matched:
&lt;ul&gt;
&lt;li&gt;Attributes row wrong or missing &amp;rarr; context pipeline gap (the dark-head failure: a head sending nothing).&lt;/li&gt;
&lt;li&gt;Attributes row correct &amp;rarr; condition-side defect (whitespace, case, type); open the audience in Code Mode, where quotes make it visible.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;data-rule = a real rule, arm = original&lt;/strong&gt;, working as designed: the control arm serves master via fallback.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;And the full ordered checklist, where each step assumes the previous ones passed:&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;#&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Check&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Where the truth shows&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;1&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;SDK key matches the rule's environment; ruleset Running; variation toggles ON&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Part 2's checklist (dashboard)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;2&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;The decision reached a rule at all&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;data-rule&lt;/span&gt; on the pill: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;default-rollout-&amp;hellip;&lt;/span&gt; means everyone-else fired&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;3&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;The attributes you think you send are the attributes you send&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Diagnostics panel, Attributes row, wrong name, wrong case, wrong type, missing key are all visible here&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;4&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;The audience condition matches those attributes byte-for-byte&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Reasons: &amp;ldquo;collectively evaluated to FALSE&amp;rdquo; while the panel shows the right value means a condition-side defect; open the audience in Code Mode, quotes make it visible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;5&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Type mismatches&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;SDK log (not reasons): &amp;ldquo;evaluated to UNKNOWN because a value of type &amp;hellip; was passed&amp;rdquo;, requires the part-2 logger&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;6&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;The head sends attributes at all&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;The dark-head failure: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;decide()&lt;/span&gt; without attributes matches nothing, and the test diagnostics will blame the rule&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;7&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Datafile freshness&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Audience edits propagate on the polling interval like every other change&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;8&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;(CMS side) the personalized block's &lt;em&gt;layer&lt;/em&gt; is reachable&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;A block personalized in a version the visitor never receives is invisible by construction&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;9&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;(CMS side) the rendering layer is registered&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Audiences screen working &amp;ne; filter running, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;AddVisitorGroupsMvc()&lt;/span&gt;, then the startup self-check proves it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Sharp edges, part 3: ranked by blood actually drawn&lt;/h2&gt;
&lt;p&gt;Same tradition, same ordering rule. Every one of these happened on this instance, this week.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. A trailing space in an audience condition.&lt;/strong&gt; The audience said &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;device = "mobile "&lt;/span&gt;. The builder keeps whatever the clipboard delivered, and exact-match fields are not trimmed. Exact match means exact: every mobile visitor silently fell through two rules to master while every dashboard stayed green. The only witness was the diagnostics panel: attributes showed &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;device=mobile&lt;/span&gt;, reasons showed &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Audiences for rule "mobile_delivery" collectively evaluated to FALSE&lt;/span&gt;, and the contradiction between those two lines &lt;em&gt;is&lt;/em&gt; the diagnosis. Code Mode confirms it fastest. JSON quotes make whitespace visible. Cost: the better part of an evening, and it produced this list's ordering.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. One &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;[OutputCache]&lt;/span&gt; attribute defeats both engines and privacy at once.&lt;/strong&gt; Swap the demo's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;no-store&lt;/span&gt; guard for platform output caching, and the measurement reads like an incident report: a desktop visitor received the &lt;em&gt;mobile&lt;/em&gt; visitor's page from cache, wrong arm, wrong personalization, and the other person's visitor ID rendered in the panel, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Age: 0&lt;/span&gt; confirming the hit. The CMS-aware output cache has been gone since the CMS 12 rewrite, and CMS 13 still gives the platform layer nothing that knows your pages are personal (verified: no output-cache type exists anywhere in the 13.1.0 assemblies). The fix is not a smarter cache key. It is classifying pages: personalized means &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;no-store&lt;/span&gt;, full stop. (The CDN analog is &amp;ldquo;ignore cookies in the cache key&amp;rdquo;, which produces the same leak at planetary scale.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Type mismatch evaluates to UNKNOWN, and only the logger says so.&lt;/strong&gt; Send &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;device&lt;/span&gt; as a boolean and &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;INCLUDE_REASONS&lt;/span&gt; reports a flat &amp;ldquo;collectively evaluated to FALSE&amp;rdquo;. The &lt;em&gt;why&lt;/em&gt; lives one layer down, in the SDK log the part-2 adapter made visible: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Audience condition {"match":"exact","name":"device","value":"mobile"} evaluated to UNKNOWN because a value of type "Boolean" was passed&lt;/span&gt;. Wire the logger on day one was part 2's advice. This is the day it pays. (Note the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;"match"&lt;/span&gt; spelling: the datafile serialization, not the dashboard's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;match_type&lt;/span&gt;.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;4. A missing attribute is quieter than a wrong one.&lt;/strong&gt; Drop &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;device&lt;/span&gt; from the dictionary and the fall-through looks identical. But this time there is no WARN anywhere, and the SDK mentions missing attributes only at debug verbosity. Absent isn't false, and absent doesn't log. The gradient of silence: wrong type warns (if you wired logs), wrong name says nothing, missing says nothing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;5. Case sensitivity, now in three places.&lt;/strong&gt; Part 2 had the case-sensitive &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;VariationKey&lt;/span&gt;. Part 3 adds attribute &lt;em&gt;names&lt;/em&gt; (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Device&lt;/span&gt; &amp;ne; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;device&lt;/span&gt;), attribute &lt;em&gt;values&lt;/em&gt; (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Mobile&lt;/span&gt; &amp;ne; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;mobile&lt;/span&gt;; our custom criterion deliberately Trim+lowercases editor input as a courtesy FX won't extend), and the geo header's &lt;em&gt;value&lt;/em&gt; (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;pl&lt;/span&gt; resolves to nothing). That last one is also the closing argument for the unification: part 2's hand-rolled parser folded case and would have accepted &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;pl&lt;/span&gt;. The native resolver doesn't. Two readers of one header with different tolerances is exactly the divergence class that breaks cross-engine and cross-head consistency: one resolver on the CMS, one written-down contract for the second head.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;6. A personalized item doesn't leak to Graph: it ceases to exist.&lt;/strong&gt; The coexistence section holds the measurement. The dangerous half is that nothing tells you: the sync succeeds, the schema is fine, the array is just empty. If your roadmap says &amp;ldquo;headless next year&amp;rdquo;, every CMS Audience you ship today is a block that will silently vanish in the migration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;7. One header steers two engines.&lt;/strong&gt; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;AddCmsClientGeolocation&lt;/span&gt; is genuinely elegant. And it concentrates trust in a single header that, exposed without an edge to strip client values, both engines will believe. The part-2 caveat, squared.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;8. The datafile is still the clock.&lt;/strong&gt; Editing an audience is a datafile change like any other. This instance picked the fix from edge #1 up in about 75 seconds on the default polling. Not a repeat of part 2's lifecycle section. Just a reminder that audience edits ride the same train, and so does your kill switch.&lt;/p&gt;
&lt;h2&gt;FAQ, the questions as people actually type them&lt;/h2&gt;
&lt;p&gt;&lt;strong&gt;Can I use Visitor Groups / CMS Audiences with a headless frontend?&lt;/strong&gt; No. Evaluation needs the CMS runtime, and the measurement here is blunter than the docs: a personalized content-area item isn't filtered for Graph consumers. It is &lt;em&gt;absent from the index entirely&lt;/em&gt;. Personalize whole page versions with FX Audiences instead. They evaluate in any SDK.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Targeted delivery or A/B test: which one do I want?&lt;/strong&gt; Deliveries deploy, experiments measure. A delivery produces no Results page at all, so the question is really &amp;ldquo;do I need to learn anything from this traffic?&amp;rdquo; If yes, experiment; if you already know and just want control and ramp, delivery.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why does my FX audience never match?&lt;/strong&gt; In observed order of likelihood: a stray character in the condition value, the wrong value case, the wrong attribute-name case, the wrong value &lt;em&gt;type&lt;/em&gt;, the attribute not sent at all. The runbook above walks the diagnostics. The short version: the panel shows what you sent, and the SDK log shows what the evaluator thought of it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Can a delivery run above an experiment?&lt;/strong&gt; No. The ruleset evaluates experiments first, and the UI encodes that as fixed sections rather than a sortable list. Carve segments out of an experiment with audiences, not with rule order.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Do I need ODP to use audiences?&lt;/strong&gt; No. Everything in this article runs on request-derived attributes. ODP (Real-Time Segments) is the path when targeting needs &lt;em&gt;customer data&lt;/em&gt; (past purchases, lifecycle stage) rather than facts about the current request.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;How fast is the kill switch?&lt;/strong&gt; One datafile propagation. This instance picked up an audience edit in ~75 seconds on default polling. The guarantee is your polling interval (or webhook latency), not the dashboard click.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Does QA and test traffic pollute my results?&lt;/strong&gt; Dashboard allowlists do: they fire impressions like any decision. This build's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;?fx_force&lt;/span&gt; deliberately doesn't, and the consent gate has a pleasant side effect: the entire automated suite (hundreds of requests per run) sends zero events, because test traffic never carries a consent cookie.&lt;/p&gt;
&lt;h2&gt;The take&lt;/h2&gt;
&lt;p&gt;The model that survives all of the above is short enough to memorize. The CMS owns &lt;strong&gt;what&lt;/strong&gt;: versioned, publishable content variations, plus fragment-level personalization wherever the CMS itself renders. FX rules own &lt;strong&gt;who and whether&lt;/strong&gt;: deterministic assignment, deliveries for rollout, experiments for measurement. Audiences, both kinds, own &lt;strong&gt;for whom&lt;/strong&gt;, and the kind you pick decides where the personalization can exist at all. CMS Audiences see everything and travel nowhere. FX Audiences see only what you send and run everywhere you do.&lt;/p&gt;
&lt;p&gt;For the business reader who skipped to the end: the mobile personalization in this demo shipped without a deploy, measured itself before an audience of three hundred synthetic visitors, survived a re-platform to a second rendering stack with zero integration code, and can be killed from a dashboard in about the time it takes to refresh a datafile. The block-level personalization shipped too. And it stopped at the CMS's edge, invisible to the headless head, unmeasured by anything but page-view counters. Both behaviors are by design. The architecture decision is choosing which design your roadmap can live with.&lt;/p&gt;
&lt;p&gt;The fragile parts haven't changed character since part 2: every failure in this article degraded to &amp;ldquo;someone quietly sees master&amp;rdquo; or &amp;ldquo;someone quietly sees too much&amp;rdquo;, and not one of them threw. The integration is still strings and dictionaries. The engineering is still making the silence loud: a diagnostics panel, an SDK logger, a startup self-check, and tests that assert the rule, not just the arm.&lt;/p&gt;
&lt;p&gt;Quiet hero, part 3: now it knows who you are. Still quiet, that's still the problem to engineer around.&lt;/p&gt;
&lt;h2&gt;Glossary&lt;/h2&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Term&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;In one sentence&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://en.wikipedia.org/wiki/Goodness_of_fit"&gt;Chi-square goodness-of-fit test&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Tests whether observed arm counts are consistent with the configured split; &amp;chi;&amp;sup2; = &amp;Sigma; (observed &amp;minus; expected)&amp;sup2; / expected.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://en.wikipedia.org/wiki/Degrees_of_freedom_(statistics)"&gt;Degrees of freedom&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Category counts free to vary; k arms give df = k &amp;minus; 1, so our three arms test at df = 2.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://en.wikipedia.org/wiki/Statistical_significance"&gt;Significance level &amp;alpha;&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;The false-alarm rate the test tolerates; this series uses &amp;alpha; = 0.001 so a healthy split almost never red-flags live.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://en.wikipedia.org/wiki/Critical_value_(statistics)"&gt;Critical value&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;The rejection threshold for the statistic, 13.816 for df = 2 at &amp;alpha; = 0.001.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://en.wikipedia.org/wiki/Standard_error"&gt;Sampling error&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Per-arm share fluctuates by &amp;asymp; &amp;radic;(p(1&amp;minus;p)/n), about 2.7 percentage points at n = 300, p = ⅓.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://en.wikipedia.org/wiki/MurmurHash"&gt;MurmurHash&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;The deterministic hash every FX SDK applies to visitor ID + experiment; the reason parity needs no coordination.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://en.wikipedia.org/wiki/Pure_function"&gt;Pure function&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Same input, same output, no side effects, FX audience evaluation in one phrase.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://en.wikipedia.org/wiki/Predicate_(mathematical_logic)"&gt;Predicate&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;A boolean-valued function; both audience kinds are predicates over different inputs.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://en.wikipedia.org/wiki/Eventual_consistency"&gt;Eventual consistency&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Distributed state converges after a delay, the datafile (~75 s observed) and the Graph index both live here.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/run-flag-deliveries"&gt;Targeted delivery&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;A rule serving one variation to an audience; deployment, not measurement, no Results page.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/manage-config-datafile"&gt;Datafile&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Configuration-as-data: the JSON snapshot of flags, rules and audiences every SDK evaluates locally.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://samnewman.io/patterns/architectural/bff/"&gt;Backend for Frontend&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;The server-side layer that keeps SDK and Graph keys out of the browser on the headless head.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://en.wikipedia.org/wiki/Defense_in_depth_(computing)"&gt;Defense in depth&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Layered safeguards: consent gate, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;no-store&lt;/span&gt;, antiforgery, origin checks, none trusted alone.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/run-a-b-tests"&gt;Allowlist&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;A per-rule list (up to fifty user IDs) pinning specific visitors to specific arms; fires impressions like normal decisions.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/forced-decision-methods-csharp"&gt;Forced decision&lt;/a&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;An SDK-level override bypassing audiences and allocation; not persistent, it lives and dies with the user context.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h2&gt;Further reading&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/audiences"&gt;Create audiences (CMS 13)&lt;/a&gt; &amp;middot; &lt;a href="https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/personalize-a-digital-experience-with-audiences"&gt;Personalize content with audiences&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/breaking-changes-in-cms-13"&gt;CMS 13 breaking changes&lt;/a&gt;, the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;AddVisitorGroupsMvc().AddVisitorGroupsUI()&lt;/span&gt; registration &amp;middot; &lt;a href="https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/install-cms13"&gt;Install CMS 13&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/interactions-between-flag-rules"&gt;Interactions between flag rules&lt;/a&gt;, experiments before deliveries; roll-down vs jump&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/run-flag-deliveries"&gt;Run flag deliveries&lt;/a&gt; &amp;middot; &lt;a href="https://support.optimizely.com/hc/en-us/articles/38816521665933"&gt;Define attributes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/forced-decision-methods-csharp"&gt;Forced decision methods (C#)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/upgrade-the-javascript-sdk-from-v5-to-v6"&gt;Upgrade the JavaScript SDK from v5 to v6&lt;/a&gt;, the explicit &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;eventProcessor&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/real-time-audiences-for-feature-experimentation"&gt;Real-Time Segments for Feature Experimentation&lt;/a&gt;, the ODP path&lt;/li&gt;
&lt;li&gt;Part 1: &lt;a href="https://pino-labs.com/blog/content-variations-cms-13-quiet-hero/"&gt;Content Variations in CMS 13, the quiet hero&lt;/a&gt; &amp;middot; Part 2: &lt;a href="/link/06d8f538fc754cb9b4362f2b31071f31.aspx"&gt;Unlock Experimentation with Content Variations in CMS 13&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</summary></entry><entry><title>Unlock Experimentation with Content Variations in CMS 13</title><link href="https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/6/unlock-experimentation-with-content-variations-in-cms-13/" /><id>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/6/unlock-experimentation-with-content-variations-in-cms-13/</id><published>2026-06-11T07:43:54.0000000Z</published><updated>2026-06-11T07:43:54.0000000Z</updated><author><name>piotr.n@rockwool.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;&lt;em&gt;Part 1 argued that Content Variations is the CMS 13 feature that didn't get the keynote but should have. This is the follow-up: wiring those variations to Optimizely Feature Experimentation, measuring whether the traffic split actually holds, and serving the same experiment from two heads (classic MVC and headless Next.js) without the experiment noticing. Everything below ran on a real CMS 13.0.2 instance with FX SDK 4.3.0; the numbers are from those runs, not from a slide.&lt;/em&gt;&lt;/p&gt;
&lt;p&gt;Part 1 ended with variations sitting in the CMS, published, versioned, queryable. Nice. Also useless, commercially, until something decides &lt;em&gt;who sees which arm&lt;/em&gt; and &lt;em&gt;whether it moved a number&lt;/em&gt;. The CMS deliberately has no opinion about that. There is no traffic-splitting UI anywhere near the Variations dropdown, and the absence is a design decision, not a gap. Delivery belongs to an experimentation engine.&lt;/p&gt;
&lt;p&gt;The entire integration between the two products is one string.&lt;/p&gt;
&lt;div style="display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 12px 16px; margin: 24px 0; padding: 22px 20px; background: #f5f5f4; border: 1px solid #e7e5e4; border-radius: 6px;"&gt;&lt;span style="background: #ffffff; border: 1px solid #d6d3d1; color: #0c0a09; font-family: Consolas,Monaco,monospace; font-size: 14px; padding: 8px 14px; border-radius: 6px;"&gt;FX VariationKey&lt;/span&gt; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 17px; font-weight: 600; color: #b85c38;"&gt;=&lt;/span&gt; &lt;span style="background: #ffffff; border: 1px solid #d6d3d1; color: #0c0a09; font-family: Consolas,Monaco,monospace; font-size: 14px; padding: 8px 14px; border-radius: 6px;"&gt;CMS Content Variation name&lt;/span&gt;&lt;/div&gt;
&lt;p&gt;That's the entire contract. FX's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Decide()&lt;/span&gt; returns a variation key; you load the CMS variation with the same name; if the names drift apart, nothing throws and visitors just quietly get the master page. Everything else in this article is plumbing around that one equality, and most of the sharp edges live exactly where you'd expect: in what happens when the equality silently fails.&lt;/p&gt;
&lt;h2&gt;Who does what&lt;/h2&gt;
&lt;p&gt;Three roles, three tools. Settle the division of labor before the code, because it's the actual selling point:&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Role&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Tool&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Editor&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;CMS Variations dropdown&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Creates and publishes the content arms. Never sees the FX dashboard.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Marketer / analyst&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;FX dashboard&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Owns the rule: traffic split, ramp, pause, Results. Never touches the CMS.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Developer&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;This article&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Wires the plumbing. Once.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;After the wiring ships, the next experiment needs no developer at all: an editor publishes new arms, a marketer points a rule at them, and the string contract does the rest. Stopping a test is one click in the dashboard, no deploy, no content rollback - the arms stay in the CMS, versioned, ready for the next round. If you have to sell this internally, that's the sentence to take into the budget meeting.&lt;/p&gt;
&lt;h2&gt;The shape of the experiment&lt;/h2&gt;
&lt;p&gt;One flag, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;demo_ab_test&lt;/span&gt;. Three FX variations: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;original&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_b&lt;/span&gt;. Two CMS Content Variations: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt; and &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_b&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;&lt;em&gt;Two&lt;/em&gt;, not three. The control arm gets &lt;strong&gt;no CMS variation&lt;/strong&gt;. When FX returns &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;original&lt;/span&gt;, the loader finds no variation by that name and falls back to the master page. The master &lt;em&gt;is&lt;/em&gt; the control. Creating a third CMS variation called &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;original&lt;/span&gt; is the first mistake I see teams make: it duplicates the master's content, and the two copies drift apart the first time an editor fixes a typo in one of them. Your control arm is then no longer your canonical page, and every lift number you report afterwards is quietly wrong. Let the fallback do its job.&lt;/p&gt;
&lt;p&gt;From the editor's chair, the whole feature is part 1's workflow with one naming rule attached: Variations dropdown, &lt;em&gt;New Variation&lt;/em&gt;, name it &lt;strong&gt;exactly&lt;/strong&gt; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt; - the key is case-sensitive, and &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Variant_A&lt;/span&gt; buys you the silent-master failure described below - then change the content and publish. Each arm publishes independently, so an unpublished draft of &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_b&lt;/span&gt; simply means that arm's visitors keep seeing master until it ships. No FX login, no flag keys, no deploys. The naming rule is the only place an editor can break the experiment, which makes it the one thing worth putting in their runbook.&lt;/p&gt;
&lt;figure style="margin: 24px 0; text-align: center;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-2/flags.png" alt="Flag list in the FX dashboard" /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;The &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;demo_ab_test&lt;/span&gt; flag in the FX dashboard.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The variation keys are defined on the flag, and each arm has its own flag on/off toggle:&lt;/p&gt;
&lt;figure style="margin: 24px 0; text-align: center;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-2/variations.png" alt="Variation keys on the flag" /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;Variation keys on the flag - these names must match the CMS variation names exactly.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;That per-variation toggle is the second quiet trap. The serving code treats &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Enabled == false&lt;/span&gt; as "no decision, serve master." If &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt; has its toggle off, a third of your traffic is bucketed into an arm that renders the control while the dashboard reports them as exposed to the variant. All three arms stay &lt;strong&gt;on&lt;/strong&gt;; the &lt;em&gt;rule&lt;/em&gt;, not the variation toggle, is your kill switch.&lt;/p&gt;
&lt;p&gt;An A/B Test rule won't save without a metric, so an event has to exist first, even if you wire conversions later:&lt;/p&gt;
&lt;figure style="margin: 24px 0; text-align: center;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-2/events.png" alt="Creating the conversion event" /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;The conversion event has to exist before the rule will save.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Then the rule itself: audience &lt;em&gt;Everyone&lt;/em&gt;, traffic allocation 100%, distribution mode Manual at 33.33/33.33/33.34, baseline &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;original&lt;/span&gt;:&lt;/p&gt;
&lt;figure style="margin: 24px 0; text-align: center;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-2/rule.png" alt="The A/B Test rule with manual distribution" /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;The A/B Test rule: Everyone, 100% allocation, manual 33.33/33.33/33.34 distribution.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;Most write-ups skip one field on the ruleset entirely: &lt;strong&gt;"Then, for everyone else."&lt;/strong&gt; It's the fallthrough for visitors who don't match the rule, either because of the audience or because they fall outside the traffic allocation. At Everyone/100% it never fires, but it documents intent for the day someone ramps the test down to 50%. Set it to &lt;strong&gt;Off&lt;/strong&gt;. Off means &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Enabled = false&lt;/span&gt; means master means control. Setting it to &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;original&lt;/span&gt; &lt;em&gt;works&lt;/em&gt; (the loader falls back identically), but those visitors get reported as if the experiment decided for them, which pollutes the boundary between "in the test" and "not in the test." Off keeps the boundary honest.&lt;/p&gt;
&lt;figure style="margin: 24px 0; text-align: center;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-2/rulesets.png" alt="Ruleset with the everyone-else fallthrough" /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;The "Then, for everyone else" fallthrough - set it to Off to keep the in-test boundary honest.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;Serving it: one decision, one load, one fallback&lt;/h2&gt;
&lt;p&gt;The controller-side code is small, and it should be. Controllers depend on an SDK-agnostic abstraction, one &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Decide&lt;/span&gt; per flag, one user context per request:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;public IActionResult Index(DemoPage currentPage)
{
    var decision = _decisions.Decide("demo_ab_test");
    var variationKey = decision.Enabled ? decision.VariationKey : null;

    var pageToRender = string.IsNullOrEmpty(variationKey)
        ? currentPage
        : _loader.Load&amp;lt;DemoPage&amp;gt;(currentPage.ContentLink, variationKey) ?? currentPage;
    // ...
}&lt;/pre&gt;
&lt;p&gt;The loader is the same &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;VariationLoaderOption&lt;/span&gt; mechanism from part 1, wrapped with a fallback:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;var options = new LoaderOptions { VariationLoaderOption.With(variationKey) };
if (contentLoader.TryGet&amp;lt;T&amp;gt;(link, options, out var variant) &amp;amp;&amp;amp; variant is not null)
    return variant;
return contentLoader.TryGet&amp;lt;T&amp;gt;(link, out var master) ? master : null;&lt;/pre&gt;
&lt;h3&gt;The decision service: the only class that knows the SDK&lt;/h3&gt;
&lt;p&gt;&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;_decisions&lt;/span&gt; above is deliberately not the Optimizely client. It's a thin abstraction, and the SDK types live in exactly one implementation behind it. That's less about architectural piety than about two practical facts: controllers become trivially testable (fake a &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;FlagDecision&lt;/span&gt;, done), and when SDK 5.x changes a signature you edit one file.&lt;/p&gt;
&lt;p&gt;The implementation is where the docs' most important serving-side rule lands: &lt;strong&gt;one user context per request, shared by every decision in that request&lt;/strong&gt;. The SDK's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;OptimizelyUserContext&lt;/span&gt; carries the visitor ID and attributes; creating it once per request (the service is DI-scoped) means the banner flag, the experiment flag, and the conversion all agree on who the visitor is:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;public class FeatureDecisionService(
    OptimizelyClient optimizely,
    IVisitorIdProvider visitorIdProvider,
    IVisitorAttributesProvider attributesProvider) : IFeatureDecisionService
{
    private OptimizelyUserContext? _userContext;

    public FlagDecision Decide(string flagKey)
    {
        var userContext = EnsureUserContext();
        if (userContext is null)
        {
            return new FlagDecision(false, null, null,
                ["User context could not be created - SDK client unusable (e.g. missing datafile)."]);
        }

        var decision = userContext.Decide(flagKey, [OptimizelyDecideOption.INCLUDE_REASONS]);
        return new FlagDecision(
            decision.Enabled,
            string.IsNullOrEmpty(decision.VariationKey) ? null : decision.VariationKey,
            decision.Variables?.ToDictionary(),
            decision.Reasons);
    }

    public void Track(string eventKey) =&amp;gt; EnsureUserContext()?.TrackEvent(eventKey);

    private OptimizelyUserContext? EnsureUserContext()
        =&amp;gt; _userContext ??= optimizely.IsValid
            ? optimizely.CreateUserContext(visitorIdProvider.GetOrCreateVisitorId(), BuildAttributes())
            : null;
}&lt;/pre&gt;
&lt;p&gt;A few lines in there do more work than they look like. The &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;IsValid&lt;/span&gt; guard: an SDK client without a datafile still hands out user contexts whose every &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Decide&lt;/span&gt; is an error decision; checking validity up front turns "mysteriously always master" into an explicit fallback with a reason string. &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;INCLUDE_REASONS&lt;/span&gt; is one of five documented decide options (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;DISABLE_DECISION_EVENT&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;ENABLED_FLAGS_ONLY&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;IGNORE_USER_PROFILE_SERVICE&lt;/span&gt;, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;EXCLUDE_VARIABLES&lt;/span&gt; are the others) and the only one this demo needs. &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;DISABLE_DECISION_EVENT&lt;/span&gt; is the other one I reach for in real projects: it asks for a flag's value without recording an impression, for the places where rendering logic needs the flag but the visitor shouldn't count as exposed. And &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;TrackEvent&lt;/span&gt; rides the &lt;em&gt;same&lt;/em&gt; context, which is the entire attribution story: no variation key travels with the conversion, ever. The stats engine joins exposure to conversion purely on the visitor ID.&lt;/p&gt;
&lt;p&gt;Audience attributes enter at context creation too. The demo derives them from the request instead of hardcoding values - device class from the User-Agent, country from CDN geo headers with an &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Accept-Language&lt;/span&gt; fallback:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;return new Dictionary&amp;lt;string, object?&amp;gt;
{
    ["device"]   = ResolveDevice(request),   // "mobile" | "tablet" | "desktop"
    ["location"] = ResolveLocation(request)  // "PL", "SE", ... or "unknown"
};&lt;/pre&gt;
&lt;p&gt;With those flowing, an FX Audience like &lt;em&gt;device = mobile&lt;/em&gt; targets a rule with zero code changes; the rule evaluates against whatever attributes the context carried. One honesty note the docs won't volunteer: geo headers like &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;CF-IPCountry&lt;/span&gt; are only trustworthy when &lt;strong&gt;your&lt;/strong&gt; edge injects them and strips client-supplied values. Exposed directly, they're attacker-supplied input, and anyone can self-select into your geo audience with a curl flag. Fine for a demo; gate it behind your CDN in production.&lt;/p&gt;
&lt;p&gt;Three failure modes funnel into the same outcome, and all three are &lt;em&gt;invisible&lt;/em&gt; in production:&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;What happened&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;What FX reports&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;What the visitor sees&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;Flag off / rule paused / no datafile&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;no decision&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;master&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;FX returned &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;original&lt;/span&gt; (control)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;exposed to &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;original&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;master - &lt;em&gt;by design&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;FX returned &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt; but the CMS variation was renamed or deleted&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;exposed to &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt;&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;master - &lt;strong&gt;silently wrong&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The third row is the one that should worry you. The experiment keeps collecting data, the dashboard keeps attributing conversions to &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt;, and every visitor in that arm is actually looking at the control. Nothing throws. No log line by default (more on the SDK's default logger below). The only place the truth surfaces is &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Decide&lt;/span&gt;'s &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;INCLUDE_REASONS&lt;/span&gt; output, which is why the demo renders a development-only diagnostics panel with the visitor ID, the resolved attributes, and the SDK's own reasoning: which rule matched, how the visitor was bucketed, or why no decision was made. Build that panel before you need it. It converts "the page always shows master and nobody knows why" from an afternoon of debugging into a single glance.&lt;/p&gt;
&lt;p&gt;Bucketing stickiness rides on a first-party cookie (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;CMS_VisitorId&lt;/span&gt;, one year, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;HttpOnly&lt;/span&gt;). One non-obvious attribute: &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;SameSite=None; Secure&lt;/span&gt;. The CMS editor previews pages in a cross-origin iframe, and without &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;SameSite=None&lt;/span&gt; the browser drops the cookie there. Every preview request then mints a new visitor and a possibly different arm, and your editors file a bug that "the page keeps flickering between variants." The experiment is fine; the cookie policy isn't.&lt;/p&gt;
&lt;p&gt;Two server hygiene points that the demo enforces and most tutorials skip: bucketed responses carry &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Cache-Control: no-store&lt;/span&gt; (a shared cache serving one visitor's arm to everyone collapses the split), and this head's conversion endpoint validates an antiforgery token - a need specific to the MVC head, because its &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;SameSite=None&lt;/span&gt; cookie rides along on cross-site POSTs. An experimentation demo whose results can be inflated by a hidden form on someone else's page is not a demo you want to give. The headless head solves the same problem differently; details where they belong, in the Next.js section.&lt;/p&gt;
&lt;h2&gt;From prose to proof, again: does 33/33/34 actually hold?&lt;/h2&gt;
&lt;p&gt;Part 1 measured storage and cache behavior. The follow-up question here is more basic and more embarrassing if you skip it: &lt;em&gt;does the traffic actually split the way the rule says?&lt;/em&gt; "Trust the hash function" is a fine answer until you're presenting and someone asks how you know.&lt;/p&gt;
&lt;p&gt;The check is an xUnit test that fires N independent requests at the running page &lt;strong&gt;without cookies&lt;/strong&gt; (the server mints a fresh visitor ID per request, so every request is a fresh bucketing), parses which arm each response declares, and runs a chi-square goodness-of-fit test against 33.33/33.33/33.34.&lt;/p&gt;
&lt;p&gt;Three runs at n = 300 against the MVC head:&lt;/p&gt;
&lt;table style="border-collapse: collapse; width: 100%; margin: 20px 0; font-size: 14px;"&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;Run&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;original&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;variant_a&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;variant_b&lt;/th&gt;
&lt;th style="border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; background: #f5f5f4;"&gt;&amp;chi;&amp;sup2; (df = 2)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;1&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;90 (30.0%)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;106 (35.3%)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;104 (34.7%)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;1.518&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;2&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;104 (34.7%)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;82 (27.3%)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;114 (38.0%)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;5.352&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;3&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;105 (35.0%)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;105 (35.0%)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;90 (30.0%)&lt;/td&gt;
&lt;td style="border: 1px solid #e0e0e0; padding: 8px 12px;"&gt;1.506&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;All pass. Run 2 is the instructive one: a 27.3% arm against an expected 33.3%, six points off, &lt;em&gt;and the test is right not to flag it&lt;/em&gt;. At n = 300 one standard deviation per arm is &amp;asymp; 2.7 percentage points, so a six-point swing is unremarkable. If your gut said "27% means the split is broken," your gut would have failed you in front of an audience. That's what the statistics are for.&lt;/p&gt;
&lt;p&gt;Steal one calibration decision from this test: the pass threshold is &amp;alpha; = 0.001 (critical value 13.816), not the textbook 0.05. At &amp;alpha; = 0.05 a &lt;em&gt;perfectly configured&lt;/em&gt; rule fails the test one run in twenty by construction, which is terrible odds for a check you might run live. The power loss is irrelevant at this effect size: a genuinely broken split like 50/25/25 produces &amp;chi;&amp;sup2; &amp;asymp; 37.5 at n = 300 and sails past either threshold. Strict alpha costs nothing and buys you a test that never cries wolf on stage.&lt;/p&gt;
&lt;p&gt;Threats to validity, disclosed as ever: the test verifies the &lt;strong&gt;FX bucketing distribution, not content delivery&lt;/strong&gt;. The page reports the arm FX assigned; if the CMS variation failed to load (the silently-wrong row above), the test still passes while every visitor sees master. It's a distribution check, not an end-to-end content assertion; the diagnostics panel covers the other half.&lt;/p&gt;
&lt;p&gt;Conversions close the loop: a CTA posts to an endpoint that calls &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;TrackEvent("demo_conversion")&lt;/span&gt; for the same visitor context, and the stats engine attributes it to whatever arm that visitor occupies. No variation key is sent; attribution is entirely on the visitor ID, which is exactly why the cookie's stability matters more than any other moving part here.&lt;/p&gt;
&lt;figure style="margin: 24px 0; text-align: center;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-2/experiment.png" alt="The experiment collecting decisions and conversions" /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;The experiment collecting decisions and conversions in the FX Results view.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;h2&gt;Plumbing the docs assume you'll remember&lt;/h2&gt;
&lt;p&gt;Three pieces of lifecycle wiring make the difference between a demo that survives a fortnight and one that fails the morning of the presentation. None of it is exotic and all of it is documented, but each item is easy to skip because the happy path works without it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The datafile is a living document.&lt;/strong&gt; Every decision the SDK makes is computed locally against the datafile, a JSON snapshot of your flags, rules, and traffic allocations served from the CDN. &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;OptimizelyFactory.NewDefaultInstance(sdkKey)&lt;/span&gt; sets up background polling (five-minute default) so dashboard changes propagate without a deploy; for latency-sensitive setups the documented upgrade is a webhook that pings your app the moment the datafile changes. The corollary people miss: &lt;em&gt;pausing an experiment in the dashboard is itself a datafile change&lt;/em&gt;. Whatever your refresh path is, that's also how fast your kill switch is.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Events are batched, and shutdown is part of the contract.&lt;/strong&gt; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;TrackEvent&lt;/span&gt; doesn't call home synchronously. The C# SDK queues impressions and conversions through a batch processor (ten events or thirty seconds, by default) to keep request latency flat. The documented flip side is that you must dispose the client on shutdown so the tail of the queue flushes. In ASP.NET Core that costs nothing if you let it: register the client as a DI singleton and the container disposes it, queue flush included, when the host stops:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;services.AddSingleton&amp;lt;OptimizelyClient&amp;gt;(provider =&amp;gt;
{
    var fxLogger = provider.GetRequiredService&amp;lt;ILoggerFactory&amp;gt;().CreateLogger("OptimizelyFX");
    var sdkLogger = new OptimizelySdkLogger(fxLogger);

    if (string.IsNullOrWhiteSpace(sdkKey))
    {
        fxLogger.LogWarning("Optimizely:SdkKey is not configured - fallback mode, all flags report disabled.");
        return new OptimizelyClient(datafile: null, logger: sdkLogger); // invalid on purpose: no polling, no blocking
    }

    OptimizelyFactory.SetLogger(sdkLogger);
    return OptimizelyFactory.NewDefaultInstance(sdkKey);
});&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Logs don't exist until you wire them.&lt;/strong&gt; That &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;SetLogger&lt;/span&gt; call looks decorative. It is the opposite. The C# SDK's shipped default logger formats every message (datafile fetch failures, unknown event keys, audience evaluation warnings) and throws the result away. The adapter is ten lines, and it's the difference between reading the cause in your console and debugging blind:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;public class OptimizelySdkLogger(ILogger logger) : OptimizelySDK.Logger.ILogger
{
    public void Log(SdkLogLevel level, string message)
        =&amp;gt; logger.Log(level switch
        {
            SdkLogLevel.DEBUG =&amp;gt; MelLogLevel.Debug,
            SdkLogLevel.INFO  =&amp;gt; MelLogLevel.Information,
            SdkLogLevel.WARN  =&amp;gt; MelLogLevel.Warning,
            SdkLogLevel.ERROR =&amp;gt; MelLogLevel.Error,
            _ =&amp;gt; MelLogLevel.Information
        }, "{Message}", message);
}&lt;/pre&gt;
&lt;h2&gt;The same experiment, headless&lt;/h2&gt;
&lt;p&gt;Part 1 called the Graph angle "where it gets interesting." Here's the cash value. The repo carries a second head, Next.js on the App Router, serving the &lt;em&gt;same experiment&lt;/em&gt; from the &lt;em&gt;same flag&lt;/em&gt; against the &lt;em&gt;same content&lt;/em&gt;, and the part that surprised people I showed it to is what I &lt;strong&gt;didn't&lt;/strong&gt; have to do: nothing. No shared session service, no API between the heads, no coordination.&lt;/p&gt;
&lt;p&gt;It works because two independent guarantees compose:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;FX bucketing is a pure function.&lt;/strong&gt; Every SDK - C#, JS, all of them - runs the same MurmurHash over &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;visitorId + experiment&lt;/span&gt;. Same visitor ID in, same arm out, on any stack.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Graph serves variations by name&lt;/strong&gt; (part 1's contract: opt-in &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variation&lt;/span&gt; argument, arms addressed via &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;_metadata.variation&lt;/span&gt;).&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;The Next.js side is small enough to show whole. Middleware mints or forwards the same &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;CMS_VisitorId&lt;/span&gt; cookie and hands the fresh ID to the current render via a request header, because the response cookie isn't visible to the request that set it:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;export function middleware(request: NextRequest) {
  const existing = request.cookies.get('CMS_VisitorId')?.value;
  if (existing) return NextResponse.next();

  const visitorId = crypto.randomUUID();
  const headers = new Headers(request.headers);
  headers.set('x-visitor-id', visitorId);

  const response = NextResponse.next({ request: { headers } });
  response.cookies.set('CMS_VisitorId', visitorId, {
    httpOnly: true, sameSite: 'lax',
    secure: process.env.NODE_ENV === 'production',
    maxAge: 60 * 60 * 24 * 365, path: '/',
  });
  return response;
}&lt;/pre&gt;
&lt;p&gt;One deliberate difference from the MVC head, before someone files it as a bug: this cookie is &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;SameSite=Lax&lt;/span&gt;, not &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;None&lt;/span&gt;. The .NET head needs &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;None&lt;/span&gt; because the CMS editor renders it inside a cross-origin preview iframe; this head doesn't do editor preview (a scope cut, not an accident), and for a top-level site &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Lax&lt;/span&gt; is the safer default - one more wall against the cross-site POST problem from earlier. The day you wire this head into the editor's preview, flip it to &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;sameSite: 'none', secure: true&lt;/span&gt; and run local dev over HTTPS (&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;next dev --experimental-https&lt;/span&gt; or a proxy), because browsers refuse &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;SameSite=None&lt;/span&gt; without &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Secure&lt;/span&gt;.&lt;/p&gt;
&lt;p&gt;CSRF splits along the same line. The conversion endpoint here is a plain Route Handler, and Route Handlers get no CSRF protection for free - that's a Server Actions perk (Next.js compares &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Origin&lt;/span&gt; against &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Host&lt;/span&gt; for those automatically). So the handler validates &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Sec-Fetch-Site&lt;/span&gt;/&lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Origin&lt;/span&gt; itself before calling &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;trackEvent&lt;/span&gt;, with the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Lax&lt;/span&gt; cookie as the first wall and the header check as the second. Where the MVC head needed an antiforgery token because of its &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;None&lt;/span&gt; cookie, this head needs an origin check because of its handler type. Same threat, two idiomatic answers.&lt;/p&gt;
&lt;p&gt;A Server Component then calls the JS SDK: same flag, same &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;INCLUDE_REASONS&lt;/span&gt;, plus one option the C# factory gives you for free and the JS SDK does not - &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;datafileOptions.autoUpdate&lt;/span&gt;. Without it, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;createInstance&lt;/span&gt; fetches the datafile exactly once and a long-running server keeps serving stale flag state until the next restart:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;const client = createInstance({
  sdkKey,
  datafileOptions: { autoUpdate: true, updateInterval: 60_000 },
});

const decision = client
  .createUserContext(visitorId)
  .decide(flagKey, [OptimizelyDecideOption.INCLUDE_REASONS]);&lt;/pre&gt;
&lt;p&gt;One scoping caveat on &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;autoUpdate&lt;/span&gt;: it's a background timer, so it assumes a long-running Node.js process - a container, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;next start&lt;/span&gt; on a VM. On ephemeral serverless functions the timer dies with the invocation and every cold start fetches its own datafile; in that world the right delivery path is the webhook-into-Edge-Config pattern from the edge section below, not polling.&lt;/p&gt;
&lt;p&gt;And the Graph query asks for the named arm with the disciplined form - &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;include: SOME&lt;/span&gt;, the original kept in the result as the fallback:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;query DemoPage($variation: [String!]) {
  _Content(
    where: { _metadata: { types: { in: ["DemoPage"] } } }
    variation: { include: SOME, value: $variation, includeOriginal: true }
  ) {
    items {
      _metadata { key displayName variation }
      ... on DemoPage { DemoTitle }
    }
  }
}&lt;/pre&gt;
&lt;p&gt;When the requested arm is missing from the index, the original arm in the same response is the fallback - the headless mirror of the MVC loader's &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;TryGet&lt;/span&gt;-then-master. The selection itself is two lines, and the arm is identified strictly by &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;_metadata.variation&lt;/span&gt;, never by dissecting an item ID:&lt;/p&gt;
&lt;pre style="background: #f6f8fa; border: 1px solid #e1e4e8; border-radius: 6px; padding: 16px; overflow: auto; font-family: Consolas,Monaco,monospace; font-size: 13px; line-height: 1.5;"&gt;const requested = items.find((i) =&amp;gt; i._metadata?.variation === variationKey);
const original  = items.find((i) =&amp;gt; !i._metadata?.variation);
const item = requested ?? original;&lt;/pre&gt;
&lt;p&gt;Same failure semantics on both stacks, which is the property you actually want, because your experiment analysis shouldn't have to care which head served the impression.&lt;/p&gt;
&lt;figure style="margin: 24px 0; text-align: center;"&gt;&lt;img style="max-width: 100%; height: auto; border: 1px solid #e1e4e8; border-radius: 6px;" src="https://pino-labs.com/blog/content-variations-cms-13-part-2/query.png" alt="Verifying the variation arms in GraphiQL" /&gt;
&lt;figcaption style="font-size: 13px; color: #666; margin-top: 6px;"&gt;Verifying the variation arms come back by name in GraphiQL.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;p&gt;The same chi-square test against the Next.js head (n = 300) came back &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;original&lt;/span&gt; 106, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_a&lt;/span&gt; 90, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;variant_b&lt;/span&gt; 104, &amp;chi;&amp;sup2; = 1.518. Pass. Two runtimes, two languages, two delivery mechanisms, one distribution, because it's one hash.&lt;/p&gt;
&lt;p&gt;Authentication, since this is the part audits ask about: the Next.js server is a Backend-for-Frontend. Graph queries carry the read-only single key in the &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Authorization: epi-single&lt;/span&gt; header (not in the URL, where it lands in every access log), and the key lives in a server-only environment variable that never reaches the client bundle. The HMAC app key and secret exist solely in the CMS's user-secrets for the sync job. The FX SDK key likewise never ships to the browser; decisions happen server-side. Single key for delivery, HMAC for management, neither in the frontend: that's the documented split, and it survives a security review.&lt;/p&gt;
&lt;h2&gt;Sharp edges, part 2: all of these drew blood&lt;/h2&gt;
&lt;p&gt;Same tradition as part 1: none of these are hypotheticals. Each cost me real time on a real instance; they're ordered by how much.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The CMS 12 Graph package kills CMS 13 at startup.&lt;/strong&gt; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Optimizely.ContentGraph.Cms&lt;/span&gt; 4.4.1 restores fine against CMS 13 (NuGet only &lt;em&gt;warns&lt;/em&gt;, NU1608), then dies on boot with a &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;TypeLoadException&lt;/span&gt; on &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;PropertyContentArea&lt;/span&gt;. CMS 13 wants the version-matched &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Optimizely.Graph.Cms&lt;/span&gt; package (13.0.2 against CMS 13.0.2; the dependency pins are exact, so the package version tracks the CMS version). The registration is &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;services.AddContentGraph()&lt;/span&gt; from &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Optimizely.Graph.DependencyInjection&lt;/span&gt;. Treat NU1608 on an Optimizely package as an error, not a warning.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The C# SDK's default logger is a no-op.&lt;/strong&gt; Not "logs to a place you forgot to check": the shipped &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;DefaultLogger.Log&lt;/span&gt; formats the message and discards it. A wrong SDK key, a blocked CDN, an unknown event key in &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;TrackEvent&lt;/span&gt;: all invisible. Ten lines of adapter forwarding &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;OptimizelySDK.Logger.ILogger&lt;/span&gt; into &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Microsoft.Extensions.Logging&lt;/span&gt;, registered via &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;OptimizelyFactory.SetLogger()&lt;/span&gt; before creating the client, and the SDK starts telling you things. Do this on day one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The JS SDK fetches the datafile once.&lt;/strong&gt; The C# factory polls in the background by default; the JS &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;createInstance&lt;/span&gt; does a one-time fetch unless you pass &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;datafileOptions: { autoUpdate: true }&lt;/span&gt;. Without it, your Next.js server keeps serving yesterday's flag state until someone restarts it, and "pause the experiment" in the dashboard pauses nothing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The empty-SDK-key fallback is a trap with the factory.&lt;/strong&gt; Feed &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;OptimizelyFactory.NewDefaultInstance&lt;/span&gt; a placeholder key and you get a real polling manager hammering the CDN for a datafile that doesn't exist (HTTP 403, every interval, forever), and the &lt;em&gt;first&lt;/em&gt; &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Decide&lt;/span&gt; blocks the full default fifteen-second readiness timeout before giving up. First page view after deploy hangs fifteen seconds; everything after is fine; nobody can reproduce it. Construct an intentionally invalid client when the key is absent and &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Decide&lt;/span&gt; fails fast instead.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The sync job and the disabled scheduler.&lt;/strong&gt; Dev environments routinely run &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;SchedulerOptions.Enabled = false&lt;/span&gt;. Content publishes index into Graph incrementally via events, but the &lt;em&gt;initial&lt;/em&gt; full sync is a scheduled job, which never fires on a disabled scheduler. Symptom: Graph authenticates, the schema is live, &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;_Content&lt;/span&gt; returns &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;total: 0&lt;/span&gt;, and every layer of your stack is working correctly. Run the synchronization job manually once from Admin &amp;rarr; Scheduled Jobs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Don't parse the item ID.&lt;/strong&gt; The pre-release docs show variation items with IDs shaped like &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;Guid_Status_Language_VariantKey&lt;/span&gt;. That's an example, not a contract. The supported address is &lt;span style="font-family: Consolas,Monaco,monospace; font-size: 0.88em; background: #f1f0ee; border-radius: 4px; padding: 2px 6px; color: #24292f;"&gt;_metadata.variation&lt;/span&gt;; the original arm is the item where it's null. String-splitting the ID is the kind of thing that works for eleven months and then doesn't.&lt;/p&gt;
&lt;h2&gt;What I deliberately left out&lt;/h2&gt;
&lt;p&gt;Scope is a feature. Here's what this build skips and why, so you can disagree on purpose rather than by accident.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;User Profile Service.&lt;/strong&gt; Out of the box, bucketing is sticky because the hash is deterministic: same visitor ID, same arm. But determinism has limits the docs are upfront about: change the traffic allocation mid-flight and some visitors re-bucket. UPS is the documented fix, a key-value store the SDK consults before hashing, pinning each visitor's first assignment forever. It's the right call for long-running production experiments; for a demo it's a database for a problem you don't have yet.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Allowlists and forced decisions.&lt;/strong&gt; The rule editor lets you pin specific user IDs to specific arms (up to fifty), and the SDK has forced-decision APIs on top. Invaluable for QA ("make me always see variant_b"), and I'd wire it into any real project's test plan. Here, the &lt;em&gt;New visitor&lt;/em&gt; button (delete the cookie, re-roll the dice) covers the demo's need with zero configuration.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Edge decisions.&lt;/strong&gt; The architecture above decides on the server, per request. The documented next step for latency-obsessed setups is pushing the datafile to the edge (Vercel's reference implementation pipes it through a webhook into Edge Config) and bucketing in middleware, before the request touches your origin. Same SDK, same hash, same arms, just earlier. It composes cleanly with everything in this article precisely because the decision logic has no server-side state to migrate.&lt;/p&gt;
&lt;h2&gt;The take&lt;/h2&gt;
&lt;p&gt;The pitch for this pairing is genuinely short. The CMS owns the &lt;em&gt;what&lt;/em&gt;: versioned, published, auditable content arms that editors manage with the tools they already know. FX owns the &lt;em&gt;who and whether&lt;/em&gt;: deterministic assignment, exposure counting, a stats engine that survives scrutiny. The integration surface between two entire products is a string equality, and the same experiment served an MVC head and a headless Next.js head with zero coordination code, holding 33/33/34 within sampling noise on both.&lt;/p&gt;
&lt;p&gt;For the business reader who skimmed to the end: this means an A/B test on real CMS content no longer requires a front-end rebuild, a personalization middleware, or a tag-manager hack that your security team hates. Editors make the arms; the dashboard runs the test; either rendering stack, today's or the one you migrate to next year, serves it unchanged. The experiment outlives your architecture decisions. That's the durable part.&lt;/p&gt;
&lt;p&gt;The fragile part is everything in the sharp-edges list, and one sentence summarizes all of it: &lt;strong&gt;the integration fails silent, never loud.&lt;/strong&gt; Wrong package, wrong key, renamed variation, stale datafile, unsynced index: every failure mode degrades to "everyone sees master" with green status everywhere. Wrap it in the diagnostics panel, the startup warnings, the distribution test. The contract is one string; the engineering is making sure you notice when the string stops matching.&lt;/p&gt;
&lt;p&gt;Quiet hero, part 2: now with a stats engine. Still quiet. Still worth it.&lt;/p&gt;
&lt;p&gt;The github repo: &lt;a href="https://github.com/pino-labs/cms13-fx-content-variations/"&gt;cms13-fx-content-variations&lt;/a&gt;.&lt;/p&gt;
&lt;h2&gt;Further reading&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/create-content-variations"&gt;Create content variations (CMS 13)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/platform-optimizely/docs/content-variation"&gt;Query content variations in Optimizely Graph for A/B testing&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/csharp-sdk"&gt;Feature Experimentation: C# SDK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/decide-methods-csharp"&gt;Decide methods and options (INCLUDE_REASONS and friends)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/customize-logger-csharp"&gt;Customize the C# SDK logger&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/event-batching-csharp"&gt;Event batching and closing the client&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/manage-config-datafile"&gt;Datafile management and webhooks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/ensure-consistent-visitor-bucketing"&gt;Ensure consistent visitor bucketing (User Profile Service)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/feature-experimentation/docs/nextjs-integration-for-the-react-sdk"&gt;Feature Experimentation: Next.js integration for the React SDK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/platform-optimizely/docs/api-single-key-auth"&gt;Optimizely Graph authentication: single key and the epi-single header&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="/link/450ac91e74d945a8acd8bba5e9a4b447.aspx"&gt;Graph security best practices (BFF pattern)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Part 1: &lt;a href="https://pino-labs.com/blog/content-variations-cms-13-quiet-hero/"&gt;Content Variations: Optimizely CMS 13's Quiet Hero&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</summary></entry><entry><title>Content Variations: Optimizely CMS 13's Quiet Hero</title><link href="https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/6/content-variations-optimizely-cms-13s-quiet-hero/" /><id>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/6/content-variations-optimizely-cms-13s-quiet-hero/</id><published>2026-06-08T06:34:41.0000000Z</published><updated>2026-06-08T06:34:41.0000000Z</updated><author><name>piotr.n@rockwool.com</name></author><category term="Blog post" /><summary type="html">&lt;p&gt;Every release has a headline act. CMS 13&amp;rsquo;s is the obvious trio. Visual Builder became the default editing surface. Optimizely Graph and Opti ID are now mandatory in every license. The whole platform moved to .NET 10. Those got the demo slots and the launch-day screenshots.&lt;/p&gt;
&lt;p&gt;The feature I keep coming back to with clients isn&amp;rsquo;t any of them. It shipped quietly, got one modest line in the GA notes, and it&amp;rsquo;s going to change how editorial teams actually work day to day. Content Variations.&lt;/p&gt;
&lt;p&gt;You only appreciate it after years of building the workarounds it kills. Let me make the case. Then, because these are field notes and not a brochure, I&amp;rsquo;ll show you where the sharp edges are and how we wrapped them.&lt;/p&gt;
&lt;h2&gt;What it actually is&lt;/h2&gt;
&lt;p&gt;Content Variations let an editor keep several published versions of the same content item, in the same language, from one source record. Not a copy in a sibling node. Not a language branch pressed into service as a fake A/B slot. The same item, with more than one published face.&lt;/p&gt;
&lt;p&gt;The mechanics are better than the one-liner suggests. Variations are delta-based. A new variation starts with no property data and stores only the properties you actually change, not a full clone. Each one keeps its own version history and its own publishing lifecycle, and you can publish it independently of the original as long as the source itself is published. If the instance has a content-approval sequence configured, variations go through it too.&lt;/p&gt;
&lt;p&gt;It shows up as a Variations dropdown in the editing toolbar, the &amp;ldquo;Select a variation of the page&amp;rdquo; control, with full autosave. You add one and name it. The name can&amp;rsquo;t include spaces and can&amp;rsquo;t start with a number. The docs use &lt;strong&gt;WinterCampaign&lt;/strong&gt; as their example. Then you&amp;rsquo;re editing a divergent version in place. You can also seed a new variation from an existing one, which copies its content instead of starting from an empty delta. There&amp;rsquo;s one structural rule. You can&amp;rsquo;t create a variation off Root.&lt;/p&gt;
&lt;p&gt;When you have a winner, you promote it back into the canonical line. That&amp;rsquo;s where the first sharp edge hides, so hold that thought.&lt;/p&gt;
&lt;h2&gt;Why this is the quiet hero&lt;/h2&gt;
&lt;p&gt;Remember how we used to do this.&lt;/p&gt;
&lt;p&gt;Want an A/B test on a landing page? You duplicated the page, ran it through whatever experimentation tool you&amp;rsquo;d bolted on, and prayed the URLs and canonical tags behaved. Want different content for different audiences? You either bought a separate personalization product or built a content area full of visibility rules until every page was a logic puzzle. Want a seasonal variant that didn&amp;rsquo;t disturb the permanent URL structure? You forked the tree and took on the cleanup debt.&lt;/p&gt;
&lt;p&gt;All of those share one disease. They treat a variation of one thing as several different things. The tree grew. Reporting fragmented. Six months later nobody could say which of the four near-identical pages was the real one, and everyone was too scared to delete any of them.&lt;/p&gt;
&lt;p&gt;Content Variations collapse that into one canonical record with several published expressions, and a single answer to &amp;ldquo;what is this page.&amp;rdquo; The experimentation, personalization, and campaign work that used to need structural gymnastics now sits inside the content item, where it belongs. It isn&amp;rsquo;t flashy. It&amp;rsquo;s structural, and structural fixes pay off for years.&lt;/p&gt;
&lt;h2&gt;The Graph angle is where it gets interesting&lt;/h2&gt;
&lt;p&gt;CMS 13 is cloud-first and headless. Headless delivery runs through Optimizely Graph. Classic ASP.NET MVC/Razor rendering still works without it, but Graph is the spine. The real question isn&amp;rsquo;t whether editors can make variations. It&amp;rsquo;s how the front end gets the right one back. That&amp;rsquo;s Graph&amp;rsquo;s job, and the design choices here are worth reading closely, because they&amp;rsquo;re also where the risk lives.&lt;/p&gt;
&lt;p&gt;Start with identity. Each variation is identified by a unique string held in a &lt;strong&gt;variation&lt;/strong&gt; field on the content item, the variation key. That field is how you address a variation in a query. There&amp;rsquo;s deliberately no tidy GUID that means &amp;ldquo;this variation.&amp;rdquo; You select one by filtering on the key.&lt;/p&gt;
&lt;p&gt;Then indexing. Every content variation, including unpublished drafts, is indexed to Graph with a unique identifier, so they&amp;rsquo;re discoverable for previews and experiments. The docs are careful about the result. A variation isn&amp;rsquo;t indexed inline with its source. Each one goes in merged with its source as a self-contained document, which is why a delivery query gets back fully resolved content instead of a bare delta. Treat the exact shape of the identifier as internal. It&amp;rsquo;s a composite of content GUID, version status, language, and variation key. The pre-release docs show it as &lt;strong&gt;Guid_Status_Language_VariantKey&lt;/strong&gt;, but that&amp;rsquo;s an example, not a contract. Address variations through &lt;strong&gt;_metadata (&lt;strong&gt;_metadata { key displayName }&lt;/strong&gt;). Don&amp;rsquo;t string-split an id.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;One default decides everything here.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;By default, GraphQL queries don&amp;rsquo;t return variations at all. A standard query gives you only the original, canonical content. To get a variation, or to preview one, you opt in explicitly.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;The opt-in is a &lt;strong&gt;variation&lt;/strong&gt; argument. &lt;strong&gt;include&lt;/strong&gt; is an enum (&lt;strong&gt;ALL | SOME | NONE&lt;/strong&gt;), &lt;strong&gt;value&lt;/strong&gt; is an array of variation keys, and &lt;strong&gt;includeOriginal&lt;/strong&gt; is a boolean.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;# Default: variations are invisible. Canonical content only.
query {
  _Content {
    items { _metadata { key displayName } }
  }
}

# Opt in to everything. Now you own whatever comes back.
query {
  _Content(variation: { include: ALL, includeOriginal: true }) {
    items { _metadata { key displayName } }
  }
}

# The disciplined form. Name the arms you actually want.
query {
  _Content(variation: { include: SOME, value: ["WinterCampaign"], includeOriginal: true }) {
    items { _metadata { key displayName } }
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The C# SDK (&lt;strong&gt;Optimizely.Graph.Cms.Query&lt;/strong&gt;) mirrors this with &lt;strong&gt;SetVariation(...)&lt;/strong&gt;, so the discipline is the same whether you write GraphQL or build the query in C#.&lt;/p&gt;
&lt;p&gt;That default is a safety valve, and a good one. A developer who&amp;rsquo;s never heard of variations writes the obvious query and never accidentally serves experimental content to the public. The flip side is that the moment you opt in, you own everything that comes back. Don&amp;rsquo;t assume a server-side default for &lt;strong&gt;includeOriginal&lt;/strong&gt;. Set it. The SDK makes you anyway. &lt;strong&gt;SetVariation takes &lt;strong&gt;includeOriginal&lt;/strong&gt; as a boolean in its direct overloads, like &lt;strong&gt;SetVariation(includeOriginal: true, "WinterCampaign")&lt;/strong&gt;, and through the options object in the builder overload. Be that explicit in raw GraphQL too.&lt;/strong&gt;&lt;/p&gt;
&lt;h2&gt;The part the launch post skipped: where this bites&lt;/h2&gt;
&lt;p&gt;This is the OMVP half, the part I&amp;rsquo;d want a client to read before they get excited.&lt;/p&gt;
&lt;h3&gt;1. Drafts are in the index, and &lt;strong&gt;include: ALL&lt;/strong&gt; is a loaded gun&lt;/h3&gt;
&lt;p&gt;Unpublished variation drafts are indexed. The default protects you. The opt-in is yours to get wrong. If a delivery query reaches for &lt;strong&gt;ALL&lt;/strong&gt; without pinning which variation it wants, or someone pastes a preview query into a production resolver, you can ship half-finished, experimental, or off-brand content straight to anonymous visitors. &lt;strong&gt;include: ALL&lt;/strong&gt; belongs in preview and selection logic, never as a casual &amp;ldquo;give me everything.&amp;rdquo; When you want one arm, use &lt;strong&gt;include: SOME&lt;/strong&gt; and name the key.&lt;/p&gt;
&lt;p&gt;Keep one distinction straight. Indexed is not the same as returned. Drafts sit in the index, but anonymous queries still resolve to published content, and pulling unpublished variation content needs a preview-authenticated context. The sharp public-leak risk is published variation content slipping out through an unscoped &lt;strong&gt;ALL&lt;/strong&gt;, with draft leakage on top wherever a preview surface gets misused.&lt;/p&gt;
&lt;h3&gt;2. Promotion is two operations, and only one is dangerous&lt;/h3&gt;
&lt;p&gt;This is the bit I see told wrong most often, so let&amp;rsquo;s be exact.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;Copy changes to Original&amp;rdquo; pushes the variation&amp;rsquo;s modified (delta) properties back onto the original. If the original already has a published common draft, you get a new draft version of the original. It doesn&amp;rsquo;t overwrite the live published version, it doesn&amp;rsquo;t auto-publish, and version history stays intact. An editor still has to publish that draft. The UI even says &amp;ldquo;Changes copied to Original!&amp;rdquo; Nothing is destroyed, so this path is recoverable. It&amp;rsquo;s a merge into a new version, not a clobber.&lt;/p&gt;
&lt;p&gt;Promoting a variation as the default version is the sharp one. It creates a new version of the source, merges the variation&amp;rsquo;s modified properties in, and can delete the variation afterwards. Because variations are deltas, only the properties the variation actually changed get merged. But deleting the variation takes that test arm&amp;rsquo;s separate history with it.&lt;/p&gt;
&lt;p&gt;The risk is real and specific. It lives in the promote-as-default-and-delete path, not in every promote. One confident click on the wrong option can wipe a test arm&amp;rsquo;s history for good. That needs process, not just permissions, and ideally a diff in front of the editor before the merge runs.&lt;/p&gt;
&lt;h3&gt;3. Deltas are bigger than editors think&lt;/h3&gt;
&lt;p&gt;Delta storage sounds surgical. It isn&amp;rsquo;t, quite. The granularity is property-level. Change one value inside a complex property and the whole property gets copied into the delta. There&amp;rsquo;s no finer sub-field delta. It bites hardest on Visual Builder Experience content. The Experience composition is itself a complex property, so changing a single block value pulls the entire Outline and its Sections into the delta. &amp;ldquo;I only touched the hero&amp;rdquo; is rarely true at the storage layer. That changes how much actually diverges, which changes what a promote or a re-sync does.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t want this one to stay prose, so I measured it at the persistence layer. The harness, the byte dump, and the before/after table are in &lt;a href="#from-prose-to-proof-two-measurements"&gt;&lt;em&gt;From prose to proof&lt;/em&gt;&lt;/a&gt; below. Short version. Touching one string inside one block of a Visual Builder Experience wrote ~3.7 KB of delta, the whole composition, for an 18-byte edit. That&amp;rsquo;s a ~207&amp;times; blow-up. Even a plain &lt;strong&gt;ContentArea&lt;/strong&gt; amplified ~37&amp;times;.&lt;/p&gt;
&lt;h3&gt;4. Reference integrity has gaps&lt;/h3&gt;
&lt;p&gt;Two limitations sit right there in the docs&amp;rsquo; own Initial Phase Limitations.&lt;/p&gt;
&lt;p&gt;Variations currently vary only localizable properties. Non-localizable, culture-invariant properties can&amp;rsquo;t diverge. The &amp;ldquo;currently&amp;rdquo; is the docs&amp;rsquo; word, so treat it as release-bound and re-check it on whatever version you ship.&lt;/p&gt;
&lt;p&gt;Softlinks aren&amp;rsquo;t generated for published variations. Any reference that exists only inside a variation, a link, a content-area item, a media usage, is invisible to the platform&amp;rsquo;s reference tracking.&lt;/p&gt;
&lt;p&gt;The consequence is the same either way. If your governance, link-checking, or &amp;ldquo;where is this used&amp;rdquo; reporting leans on reference data, it&amp;rsquo;s partly blind to variation-only references. Add experimental URLs to that and you have a real duplicate-content and broken-reference risk that won&amp;rsquo;t show up where you normally look.&lt;/p&gt;
&lt;h3&gt;5. Governance scales with the surface, and most teams forget&lt;/h3&gt;
&lt;p&gt;Approval workflow covers variations if you&amp;rsquo;ve set it up. But variations multiply the number of things that can be published. If your approval gate only sits on the main editorial flow, you&amp;rsquo;ve quietly opened a side door. Nothing in the box stops a page from collecting fourteen orphaned variations nobody remembers making. Variation sprawl is the new tree sprawl. It&amp;rsquo;s just harder to see, because it doesn&amp;rsquo;t show up as nodes.&lt;/p&gt;
&lt;h3&gt;6. Index state is an environment footgun&lt;/h3&gt;
&lt;p&gt;A feature flag controls variation indexing, and flipping it off takes the index with it. Clean kill switch, and an easy thing to get wrong across environments. Flag and index state can drift, and an indexing gap is silent. That silent failure is field observation, not a documented guarantee. A variation that should be live just isn&amp;rsquo;t in the index, your previews and experiments quietly vanish, and nothing throws. The pre-release content-variations docs call this out and the GA notes don&amp;rsquo;t repeat it, so confirm it on the build you ship. I wouldn&amp;rsquo;t hard-code a flag name in a runbook either. Treat it as the variations feature toggle and check it per environment. Silent failure is the worst kind.&lt;/p&gt;
&lt;h3&gt;7. Pulling variation sets at scale costs you on caching&lt;/h3&gt;
&lt;p&gt;This one is a general Graph property, and it&amp;rsquo;s documented. Broad &lt;strong&gt;items&lt;/strong&gt; queries trash your cache. They force it to &amp;ldquo;account for potential changes across all content,&amp;rdquo; in the docs&amp;rsquo; words, so it invalidates constantly. Narrow single-&lt;strong&gt;item&lt;/strong&gt; lookups cache cleanly and hit far more often. The queries you write to pull variations across content are exactly the broad &lt;strong&gt;items&lt;/strong&gt; kind. The docs don&amp;rsquo;t tie variations to caching directly, but the query shape you reach for is the one that caches worst. On a busy site that&amp;rsquo;s a real line item, not a footnote.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;Trash your cache&amp;rdquo; is a claim with no number attached, so I attached one. The environment, the harness, and the measured hit-rate table for &lt;strong&gt;item&lt;/strong&gt; vs &lt;strong&gt;items&lt;/strong&gt; and &lt;strong&gt;SOME&lt;/strong&gt; vs &lt;strong&gt;ALL&lt;/strong&gt; are in &lt;a href="#from-prose-to-proof-two-measurements"&gt;&lt;em&gt;From prose to proof&lt;/em&gt;&lt;/a&gt; below. Short version. In a deterministic cache model of a 5,000-page set, a narrow &lt;strong&gt;item&lt;/strong&gt; lookup held a 99.2% hit-rate while &lt;strong&gt;items(limit:100)&lt;/strong&gt; with &lt;strong&gt;include: ALL&lt;/strong&gt; collapsed to 7.7%. A single base publish invalidates one &lt;strong&gt;item&lt;/strong&gt; key but hundreds of broad-listing keys.&lt;/p&gt;
&lt;p&gt;None of this is a reason to avoid the feature. It&amp;rsquo;s a reason to wrap it.&lt;/p&gt;
&lt;h2&gt;From prose to proof: two measurements&lt;/h2&gt;
&lt;p&gt;Two of the edges above are the kind of claim a reader has to take on faith. The delta is &amp;ldquo;coarser than it looks.&amp;rdquo; Broad queries &amp;ldquo;trash the cache.&amp;rdquo; Faith is the wrong currency for an architecture decision. Both are measured below, with the environment described and a harness you can re-run. Both experiments ship in the add-on&amp;rsquo;s &lt;a href="#"&gt;&lt;strong&gt;tools/research/&lt;/strong&gt;&lt;/a&gt; folder and were run for this article. &lt;strong&gt;dotnet run -c Release&lt;/strong&gt; reproduces the tables byte-for-byte (deterministic, seeded), and the exact build/date stamp lives in the generated &lt;strong&gt;RESULTS.md&lt;/strong&gt;. One honesty note up front. The delta figures are real serialization measurements of representative property graphs, not a dump from a live store (&lt;strong&gt;DeltaProbe.cs&lt;/strong&gt; is provided for that). The cache figures come from a deterministic discrete-event model of Graph&amp;rsquo;s documented invalidation rule. The model measures the hit-rate that follows from that rule under stated fragmentation assumptions. The direction is robust, but absolute magnitudes, and any wall-clock latency (p50/p99), need the live gateway and the &lt;strong&gt;graph-cache.k6.js&lt;/strong&gt; script, which isn&amp;rsquo;t run here. The full list of what these caveats mean is in the &lt;em&gt;Threats to validity&lt;/em&gt; section below.&lt;/p&gt;
&lt;h3&gt;Measuring the delta: what &amp;ldquo;I only touched the hero&amp;rdquo; actually writes&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The claim under test.&lt;/strong&gt; Changing a single value inside a complex property copies the entire property into the variation delta, and for a Visual Builder Experience that means the whole Outline + Sections, not the field you touched.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Method.&lt;/strong&gt; A CMS 13 variation version stores only the properties that diverge from its source. There&amp;rsquo;s no public &amp;ldquo;give me the raw delta bytes&amp;rdquo; call, but you can reconstruct it faithfully. Load the source version and the variation version, walk the property bag, and for each property serialize both values and compare. Properties whose serialized bytes differ are the delta. Their serialized size is what diverged. This byte-diff is the same set Graph merges into the variation document, so it&amp;rsquo;s a faithful proxy for the changed-property payload a variation carries. The exact on-disk bytes can differ, which is what the Threats to validity note covers.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// DeltaProbe.cs - reconstructs a variation's delta by byte-diffing it against its source version.
// Run once on a freshly-seeded variation (expect: empty delta), then again after touching ONE block.
public sealed record DeltaRow(string Property, string Type, bool InDelta, int Bytes);

public sealed class DeltaProbe
{
    private readonly IContentLoader _loader;
    public DeltaProbe(IContentLoader loader) =&amp;gt; _loader = loader;

    public IReadOnlyList&amp;lt;DeltaRow&amp;gt; Diff(ContentReference original, ContentReference variation)
    {
        var src = _loader.Get&amp;lt;IContent&amp;gt;(original);
        var var_ = _loader.Get&amp;lt;IContent&amp;gt;(variation);
        var rows = new List&amp;lt;DeltaRow&amp;gt;();

        foreach (var vp in var_.Property)
        {
            var sp = src.Property[vp.Name];
            var sb = Serialize(sp?.Value);
            var vb = Serialize(vp.Value);
            var inDelta = !sb.AsSpan().SequenceEqual(vb);   // differs =&amp;gt; part of the delta
            rows.Add(new DeltaRow(vp.Name, vp.GetType().Name, inDelta, inDelta ? vb.Length : 0));
        }
        return rows;
    }

    // Stable, value-only serialization so the comparison reflects content, not object identity.
    private static byte[] Serialize(object? value) =&amp;gt; value is null
        ? Array.Empty&amp;lt;byte&amp;gt;()
        : Encoding.UTF8.GetBytes(JsonSerializer.Serialize(value, JsonOpts));

    private static readonly JsonSerializerOptions JsonOpts =
        new() { ReferenceHandler = ReferenceHandler.IgnoreCycles, WriteIndented = false };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Environment.&lt;/strong&gt; Figures come from the deterministic harness in &lt;strong&gt;tools/research&lt;/strong&gt;. It builds two representative property graphs. One is a standard page with a&amp;nbsp;&lt;strong&gt;ContentArea of local blocks. The other is a Visual Builder Experience of five sections (hero, a 3-up feature row, a story split, a product band, a signup). It serializes each, applies exactly one nested edit (the hero headline &lt;strong&gt;"Winter Sale"&lt;/strong&gt; &amp;rarr; &lt;strong&gt;"Winter Sale - Up to 40% off", an 18-byte change), and re-serializes. The &lt;strong&gt;DeltaProbe.cs&lt;/strong&gt; above is the same byte-diff run against live content via &lt;strong&gt;IContentLoader&lt;/strong&gt;. The harness just removes the need for a seeded site to reproduce the effect.&lt;/strong&gt;&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Result, standard page (ContentArea).&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;In delta?&lt;/th&gt;
&lt;th&gt;Serialized bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Heading&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PropertyString&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MainBody&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PropertyXhtmlString&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;MainContentArea&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;PropertyContentArea&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;664&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The headline lives in a local block inside &lt;strong&gt;MainContentArea&lt;/strong&gt;. Editing it marked the whole content-area property dirty, every block, not just the changed one. 664 bytes landed in the delta for an 18-byte edit, a ~37&amp;times; amplification.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Result, Visual Builder Experience page.&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Property&lt;/th&gt;
&lt;th&gt;Type&lt;/th&gt;
&lt;th&gt;In delta?&lt;/th&gt;
&lt;th&gt;Serialized bytes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Name&lt;/td&gt;
&lt;td&gt;(metadata)&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Composition&lt;/td&gt;
&lt;td&gt;Experience composition (Outline + Sections)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;yes&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3,727&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;There it is, at the persistence layer. The Experience composition is one complex property. Touching one string in one block rewrote the entire Outline and all five sections into the delta. That&amp;rsquo;s 3,727 bytes against the 18 bytes the edit actually represents, a ~207&amp;times; amplification. And that&amp;rsquo;s a deliberately modest composition. A production Experience with imagery, more blocks, and richer settings runs to tens of KB, which pushes the ratio into the thousands. &amp;ldquo;I only touched the hero&amp;rdquo; is false where it counts. Seed twenty audience variations of this one page and you&amp;rsquo;ve written ~75 KB of near-duplicate composition, and every promote or sync moves all of it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Why it matters beyond disk.&lt;/strong&gt; That delta is exactly what a promote merges and what &lt;em&gt;Sync from default&lt;/em&gt; has to reconcile. A coarse delta means a coarse merge surface. More properties in play, more chances for a stale-conflict, more to diff in front of the editor. The byte number isn&amp;rsquo;t vanity. It&amp;rsquo;s the size of the blast radius.&lt;/p&gt;
&lt;h3&gt;Measuring the cache: &lt;strong&gt;item&lt;/strong&gt; vs &lt;strong&gt;items&lt;/strong&gt;, and the cost of &lt;strong&gt;include: ALL&lt;/strong&gt;&lt;/h3&gt;
&lt;p&gt;&lt;strong&gt;The claim under test.&lt;/strong&gt; Broad &lt;strong&gt;items&lt;/strong&gt; queries cache badly, narrow &lt;strong&gt;item&lt;/strong&gt; lookups cache well, and opting into variations with &lt;strong&gt;include: ALL&lt;/strong&gt; makes it worse. A single master publish invalidates broadly.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Environment.&lt;/strong&gt; Two layers. The hit-rate numbers below come from a deterministic discrete-event model (&lt;strong&gt;tools/research&lt;/strong&gt;, run on .NET 10) of Graph&amp;rsquo;s documented cache rule. A result is keyed by its query, and a write invalidates every key whose result could change. The model uses the article&amp;rsquo;s dataset shape, 5,000 pages and 500 varied &amp;times; 3 = 1,500 variants, exercised over a fixed 100-id working set with 200,000 reads and one publish per 100 reads. It models the decisive variable explicitly. A narrow &lt;strong&gt;item&lt;/strong&gt; lookup has one key per id, while a broad &lt;strong&gt;items&lt;/strong&gt; listing fragments across filter/sort/page keys (150), with &lt;strong&gt;SOME&lt;/strong&gt; (300) and &lt;strong&gt;ALL&lt;/strong&gt; (600) fragmenting further. The &lt;strong&gt;graph-cache.k6.js&lt;/strong&gt; script below is the live counterpart that measures p50/p99 latency against a real gateway. k6 wasn&amp;rsquo;t run for this article, so latency is left to you. The model measures the hit-rate that follows from the cache-key/invalidation rule under those stated fragmentation assumptions. The ordering is robust. The exact percentages move with the assumptions.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// graph-cache.k6.js &amp;mdash; compares cache behaviour of item vs items, and SOME vs ALL.
import http from 'k6/http';
import { Trend, Rate } from 'k6/metrics';

const URL = `https://cg.optimizely.com/content/v2?auth=${__ENV.KEY}`;
const hit = {};      // Rate per scenario
const lat = {};      // Trend per scenario
for (const s of ['item', 'item_some', 'items', 'items_some', 'items_all']) {
  hit[s] = new Rate(`hit_${s}`);
  lat[s] = new Trend(`lat_${s}`, true);
}

const Q = {
  item:       (id) =&amp;gt; `{ _Content(where:{_metadata:{key:{eq:"${id}"}}}, limit:1){ items{ _metadata{ key displayName } } } }`,
  item_some:  (id) =&amp;gt; `{ _Content(where:{_metadata:{key:{eq:"${id}"}}}, limit:1, variation:{ include: SOME, value:["WinterCampaign"], includeOriginal:true }){ items{ _metadata{ key } } } }`,
  items:      ()   =&amp;gt; `{ _Content(limit:100){ items{ _metadata{ key displayName } } } }`,
  items_some: ()   =&amp;gt; `{ _Content(limit:100, variation:{ include: SOME, value:["WinterCampaign"], includeOriginal:true }){ items{ _metadata{ key } } } }`,
  items_all:  ()   =&amp;gt; `{ _Content(limit:100, variation:{ include: ALL, includeOriginal:true }){ items{ _metadata{ key } } } }`,
};

export default function () {
  const id = IDS[Math.floor(Math.random() * IDS.length)]; // fixed 100-id working set
  for (const s of Object.keys(Q)) {
    const body = JSON.stringify({ query: s.startsWith('item') ? Q[s](id) : Q[s]() });
    const res = http.post(URL, body, { headers: { 'Content-Type': 'application/json' } });
    const cached = (res.headers['X-Cache'] || res.headers['Cf-Cache-Status'] || '').toLowerCase().includes('hit');
    hit[s].add(cached);
    lat[s].add(res.timings.duration);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Result, hit-rate by query shape (model).&lt;/strong&gt;&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query shape&lt;/th&gt;
&lt;th&gt;Variation arg&lt;/th&gt;
&lt;th&gt;Reads&lt;/th&gt;
&lt;th&gt;Origin fetches&lt;/th&gt;
&lt;th&gt;Hit-rate&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;item(key)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;none (canonical)&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;1,673&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99.2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;item(key)&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;SOME ["WinterCampaign"]&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;1,885&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;99.1%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;items(limit:100)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;129,679&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;35.2%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;items(limit:100)&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;SOME [key]&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;165,132&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;17.4%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;items(limit:100)&lt;/td&gt;
&lt;td&gt;ALL + &lt;strong&gt;includeOriginal&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;200,000&lt;/td&gt;
&lt;td&gt;184,516&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;7.7%&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;Hit-rate (modelled), drawn so the cliff is obvious:&lt;/p&gt;
&lt;pre&gt;&lt;strong&gt;item            ██████████████████████████████████████████████████ &amp;asymp;99%
item + SOME     ██████████████████████████████████████████████████ &amp;asymp;99%
items           ██████████████████                                 &amp;asymp;35%
items + SOME    █████████                                          &amp;asymp;17%
items + ALL     ████                                               &amp;lt;10%
                0%        25%        50%        75%        100%&lt;/strong&gt;&lt;/pre&gt;
&lt;p&gt;Two readings. First, query shape dominates. A narrow &lt;strong&gt;item&lt;/strong&gt; lookup is a clean, stable per-id key that effectively always hits, 1,673 origin fetches across 200,000 reads (99.2%). A broad &lt;strong&gt;items&lt;/strong&gt; listing fragments across keys, each reused less and invalidated whenever any member changes, so it fell to 35.2%. Second, opting into variations compounds it. &lt;strong&gt;SOME&lt;/strong&gt; halves the broad hit-rate again (17.4%). &lt;strong&gt;ALL&lt;/strong&gt; folds all 1,500 variants into the candidate space and floors it at 7.7%, about 110&amp;times; the origin traffic of the narrow lookup (184,516 vs 1,673 fetches).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Result, invalidation cost of one base publish (model).&lt;/strong&gt; Warm every key, publish a single base content inside the working set, then count the keys each shape must re-fetch from origin.&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query shape&lt;/th&gt;
&lt;th&gt;Warm keys&lt;/th&gt;
&lt;th&gt;Keys invalidated by 1 publish&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;item&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;item + SOME&lt;/td&gt;
&lt;td&gt;100&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;items&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;150&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;150&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;items + SOME&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;300&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;300&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;items + ALL&lt;/td&gt;
&lt;td&gt;600&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;600&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That&amp;rsquo;s the asymmetry that matters. A base publish punches a single hole in the &lt;strong&gt;item&lt;/strong&gt; cache, the published id, nobody else. The same publish poisons every broad listing key that could contain it, because the gateway can&amp;rsquo;t prove it doesn&amp;rsquo;t, so it invalidates the whole family. The variation-inventory queries you&amp;rsquo;re tempted to write (&amp;ldquo;show me every variation across the site&amp;rdquo;) are precisely this broad &lt;strong&gt;items&lt;/strong&gt; shape. That&amp;rsquo;s why the Auditor&amp;rsquo;s grid resolves variation keys through narrow per-&lt;strong&gt;item&lt;/strong&gt; lookups and batches the deliverability probe one round-trip per content id, instead of one broad &lt;strong&gt;items(variation: ALL)&lt;/strong&gt; sweep. The measurement drove the design.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Repeatability.&lt;/strong&gt; The whole harness ships in the add-on&amp;rsquo;s &lt;strong&gt;tools/research/&lt;/strong&gt; folder: &lt;strong&gt;DeltaExperiment.cs&lt;/strong&gt;, &lt;strong&gt;CacheExperiment.cs&lt;/strong&gt;, the &lt;strong&gt;graph-cache.k6.js&lt;/strong&gt; live script, and the generated &lt;strong&gt;RESULTS.md&lt;/strong&gt;. &lt;strong&gt;dotnet run -c Release&lt;/strong&gt; reproduces both tables byte-for-byte (the run is deterministic, seeded &lt;strong&gt;1337&lt;/strong&gt;). Point &lt;strong&gt;DeltaProbe.cs&lt;/strong&gt; and the k6 script at a live CMS 13 + Graph environment for your own live numbers. Absolute values move with dataset size, composition size, and gateway region. The cliff between &lt;strong&gt;item&lt;/strong&gt; and &lt;strong&gt;items + ALL&lt;/strong&gt;, and the property-level delta amplification on Experience content, do not.&lt;/p&gt;
&lt;h3&gt;Threats to validity&lt;/h3&gt;
&lt;p&gt;Both experiments confirm the direction of the claims. Be precise about what they don&amp;rsquo;t prove.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Delta is a serialization model, not a live store dump.&lt;/strong&gt; It demonstrates the mechanism (any nested change re-serializes the whole complex property) using representative graphs. The exact on-disk bytes CMS 13 writes can differ. Run &lt;strong&gt;DeltaProbe.cs&lt;/strong&gt; against real content for authoritative figures.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Delta magnitude scales with composition size.&lt;/strong&gt; The 207&amp;times; here is from a deliberately modest five-section Experience (3.7 KB). A production Experience with imagery and richer settings amplifies far more. The ratio is not a constant.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The ContentArea result assumes local/inline blocks.&lt;/strong&gt; If the area holds shared block references, editing the referenced block changes a different content item, not the page&amp;rsquo;s delta. The Experience composition embeds its blocks, so that case holds robustly.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cache numbers are a model, not a live-gateway measurement.&lt;/strong&gt; Hit-rates follow from assumed key-fragmentation counts (150/300/600) and an invalidation rule where every listing key covers the whole working set. That rule maximises the broad-query penalty. A gateway that scopes invalidation more narrowly would show a softer, same-direction cliff. Only &lt;strong&gt;graph-cache.k6.js&lt;/strong&gt; against a real gateway settles the magnitudes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No latency was measured.&lt;/strong&gt; Every p50/p99 statement is deferred to the live k6 run. None is asserted here.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these soften the practical takeaway. Narrow lookups and bounded variation scoping win. Coarse deltas and &lt;strong&gt;include: ALL&lt;/strong&gt; lose. But they mark exactly where &amp;ldquo;measured&amp;rdquo; ends and &amp;ldquo;modelled&amp;rdquo; begins.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Live spot-check.&lt;/strong&gt; I did point a read-only delivery key at a real Graph gateway to ground-truth the mechanism, not the magnitudes. Confirmed live. The schema accepts the &lt;strong&gt;variation: { include: ALL | SOME | NONE, includeOriginal }&lt;/strong&gt; argument. The gateway caches with &lt;strong&gt;Cache-Control: public, max-age=86400&lt;/strong&gt; and reports &lt;strong&gt;CF-Cache-Status: HIT/MISS&lt;/strong&gt;. A cache-miss &lt;strong&gt;include: ALL&lt;/strong&gt; query was the costliest at the origin (p50 ~506 ms vs ~406 ms for a narrow &lt;strong&gt;item&lt;/strong&gt; miss). What that source couldn&amp;rsquo;t validate (it held 24 items and zero variations, and the key can&amp;rsquo;t publish) is the hit-rate cliff at scale and the invalidation asymmetry, which still rest on the model. Mechanism confirmed on a live gateway, magnitudes still modelled.&lt;/p&gt;
&lt;h2&gt;Where the add-on comes in: PiNo Labs Variations Auditor&lt;/h2&gt;
&lt;p&gt;Most of the risks above aren&amp;rsquo;t bugs. They&amp;rsquo;re sharp edges that come with a powerful primitive being honest about what it does. The platform gives you the capability and a sensible default. It doesn&amp;rsquo;t have opinions about how your team should use it. That gap is what a thin add-on fills.&lt;/p&gt;
&lt;p&gt;In our Foundation solution we packaged the governance into a small CMS 13 add-on, &lt;strong&gt;PiNo.Labs.VariationsAuditor&lt;/strong&gt;, that turns the variation estate from a blind spot into something you can see, measure, and act on. It installs as one self-registering protected shell module: an &lt;strong&gt;IConfigurableModule&lt;/strong&gt;, a &lt;strong&gt;ProtectedModuleOptions&lt;/strong&gt; self-registration, an &lt;strong&gt;/api/auditor/*&lt;/strong&gt; REST surface, a shell menu entry, and a React UI embedded in the assembly and served straight from the DLL, with no &lt;strong&gt;wwwroot&lt;/strong&gt; copy step. It authorizes against the canonical CMS roles Opti ID syncs onto the principal, so the gate behaves the same on DXP and locally. Every external dependency it leans on, Graph most of all, is resolved optionally and degrades gracefully when it&amp;rsquo;s missing.&lt;/p&gt;
&lt;p&gt;A fair question before the code. Every platform seam the snippets touch (shell-module and menu registration, &lt;strong&gt;IConfigurableModule&lt;/strong&gt;/&lt;strong&gt;ProtectedModuleOptions&lt;/strong&gt;, the &lt;strong&gt;IContentEvents.PublishedContent&lt;/strong&gt; hook, the versioning APIs) sits inside CMS 13&amp;rsquo;s breaking-change surface. They&amp;rsquo;re shown as they compile against the CMS 13 / .NET 10 assemblies the add-on actually ships on, written for those breaking changes rather than refactored from CMS 12. Here&amp;rsquo;s what it does, mapped to the edges above.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;It starts from the one inconvenient truth the platform hands you.&lt;/strong&gt; There&amp;rsquo;s no in-process, site-wide API for &amp;ldquo;all content that has variations.&amp;rdquo; A CMS 13 content variation is just a content version carrying a non-empty &lt;strong&gt;IVersionable.Variation&lt;/strong&gt; key, and a variation&amp;rsquo;s identity is a tuple of content, language, and variation key, not a GUID.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// A CMS 13 "content variation" is a version with a non-empty IVersionable.Variation key.
var variants = versionRepository
    .List(new VersionFilter { ContentLink = contentLink }, 0, max, out _)
    .Where(v =&amp;gt; !string.IsNullOrEmpty(v.Variation));     // empty key == language baseline, not a variant&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Discovery uses Graph to enumerate the site, then reads the authoritative variation keys from &lt;strong&gt;IContentVersionRepository&lt;/strong&gt;. If Graph is down or hasn&amp;rsquo;t finished indexing, it falls back to an in-process content-tree walk. The grid works on day one, indexed or not.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A pre-promotion diff, so promotion is a decision and not a reflex (edges #2 and #3).&lt;/strong&gt; Every destructive action runs a mandatory, conflict-aware dry-run first. The divergence engine computes a property-level diff and a structural content-area diff, so the editor sees exactly which properties will land back on the original. It also catches the dangerous case the platform won&amp;rsquo;t warn about. Promoting a variation whose source has moved on since.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Stale-conflict guard: refuse to silently overwrite newer master changes on promote.
var stale = divergence.GetStaleProperties(target);
if (action == BulkActionType.Promote &amp;amp;&amp;amp; stale.Count &amp;gt; 0 &amp;amp;&amp;amp; !cmd.ForceOverwriteStale)
{
    return Blocked(
        "CRITICAL REVERSION RISK: promoting would overwrite newer changes on the original for: " +
        string.Join(", ", stale) + ". Re-run with ForceOverwriteStale to proceed.");
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Promote is a delta merge, full stop. It overlays only the properties the variation actually overrode onto a writable clone of the published original, and reports which ones it touched. That keeps CMS 13&amp;rsquo;s delta model intact instead of flattening master-only properties. We pulled an earlier &amp;ldquo;replace everything&amp;rdquo; mode because it broke exactly that model. The same dry-run, lock, and status gate guards Unpublish and Delete. The inverse action, &amp;ldquo;Sync from default,&amp;rdquo; lets an editor pull the original&amp;rsquo;s current value back into a stale variation, which is the safe fix for edge #3.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A site-wide orphan and sprawl report, so cleanup actually happens (edge #5).&lt;/strong&gt; The grid is the inventory nobody had. Every variation across the site, the audience it targets (the key resolved to a readable visitor group, because editors think in audiences, not keys), its divergence state, and a needs-attention filter. Multi-select plus a dry-run bulk bar turns unpublishing or deleting a pile of orphaned arms into a two-click, fully previewed operation. Sprawl you can retire.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A deliverability probe that turns silent index failures loud (edges #4 and #6).&lt;/strong&gt; This is the canary for &amp;ldquo;my variation should be live but isn&amp;rsquo;t in Graph.&amp;rdquo; It checks each published variant against the index, batched and de-duplicated per content id so a grid page costs one round-trip instead of N, and flags anything known to CMS but missing from the index as an orphan. Softlinks are blind to variations, so this Graph-backed inventory is the &amp;ldquo;where are my variations, and are they being served&amp;rdquo; view the reference system can&amp;rsquo;t give you. It doesn&amp;rsquo;t lie the other way either. If the Graph client isn&amp;rsquo;t registered at all, the probe returns deliverable rather than painting every variant red.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;// Optional Graph client, never required. No AddGraphContentClient()? Degrade, don't crash.
_graph = serviceProvider.GetService&amp;lt;IGraphContentClient&amp;gt;();
...
if (_graph == null) return GraphStatus.Deliverable;        // unknown != orphan; never mislabel
var indexed = await IsIndexedAsync(identity.ContentId, ct);
return indexed ? GraphStatus.Deliverable : GraphStatus.IndexGap;   // IndexGap == orphan, surfaced in the grid&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;Proactive drift notifications, so editors come back when something changes (edge #5 again).&lt;/strong&gt; A &lt;strong&gt;StaleDriftDetector&lt;/strong&gt; subscribes to &lt;strong&gt;IContentEvents.PublishedContent&lt;/strong&gt;. When a default publishes, it recomputes its variants&amp;rsquo; staleness and tells the owner of each newly stale variation. The default just moved, your audience is now on yesterday&amp;rsquo;s content for these three properties. It&amp;rsquo;s bounded, de-duplicated per variant and save, runs in its own DI scope, and is wrapped so it can never fail the publish that triggered it. Delivery fans out across pluggable &lt;strong&gt;INotificationChannel&lt;/strong&gt;s (webhook to Slack/Teams, SMTP email, log), all off until the host opts in.&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;public void Initialize(InitializationEngine context)
    =&amp;gt; context.Locate.ContentEvents().PublishedContent += OnDefaultPublished; // drift stops being silent&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;strong&gt;And the one guardrail that belongs at the delivery layer, not in the editor (edge #1).&lt;/strong&gt; The auditor governs the editorial and estate side, which is the side it can see. The &lt;strong&gt;include: ALL&lt;/strong&gt; problem is a delivery concern, and the cheapest place to close it is a thin convention around your Graph client. A typed query wrapper, or a CI lint over your resolvers, that refuses an unscoped &lt;strong&gt;ALL&lt;/strong&gt; on the production delivery key and makes callers pass an explicit variation key or a preview context. The platform default already protects the naive query. This protects you from the clever one. Run both and you&amp;rsquo;ve covered what&amp;rsquo;s in the estate and what reaches the visitor.&lt;/p&gt;
&lt;p&gt;The point isn&amp;rsquo;t the code. It&amp;rsquo;s the posture. The platform ships the engine and a sensible default. You ship the seatbelts that fit your team&amp;rsquo;s risk tolerance. That&amp;rsquo;s what an add-on is for.&lt;/p&gt;
&lt;p&gt;The full source of the add-on is available on GitHub at &lt;a href="https://github.com/pino-labs/pino-labs-variations-auditor"&gt;https://github.com/pino-labs/pino-labs-variations-auditor.&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;The take&lt;/h2&gt;
&lt;p&gt;A year from now, Content Variations won&amp;rsquo;t be what people remember about CMS 13. Visual Builder and the headless story will take that slot. But it&amp;rsquo;s the feature that quietly removes a whole category of structural debt teams have carried for years, and it deserved more than one launch-day line.&lt;/p&gt;
&lt;p&gt;Just go in with your eyes open. Drafts live in the index. The opt-in is yours to misuse. Promotion has two doors, and one of them deletes history. Deltas are coarser than they look. Softlinks and index state have edges. Put a thin layer of governance and tooling around the feature: a pre-promotion diff, a site-wide orphan report, a deliverability canary, a delivery-side guard. Then let your editors do the thing they&amp;rsquo;ve always wanted, which is to experiment on a page without turning the content tree into an archaeological dig.&lt;/p&gt;
&lt;p&gt;Quiet hero. Sharp edges. Worth it..&lt;/p&gt;
&lt;h2&gt;Further reading&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/content-management-system/v13-Pre-Release/docs/content-variations"&gt;Content Variations (CMS 13 developer documentation)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/content-management-system/v1.0.0-CMS-SaaS/docs/create-content-variation"&gt;Create content variations (editorial workflow)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/platform-optimizely/docs/content-variation"&gt;Query content variations in Optimizely Graph&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/platform-optimizely/docs/graphql-best-practices"&gt;GraphQL best practices (&lt;strong&gt;items&lt;/strong&gt; vs &lt;strong&gt;item&lt;/strong&gt; caching)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/cms-13-overview"&gt;CMS 13 overview (architecture, mandatory components)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.developers.optimizely.com/content-management-system/v13.0.0-CMS/docs/cms-13-user-interface"&gt;CMS 13 user interface (toolbar and Variations control)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.optimizely.com/hc/en-us/articles/44734633809037-2026-Optimizely-CMS-13-general-availability-GA-release-notes"&gt;CMS 13 GA release notes&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://support.optimizely.com/hc/en-us/articles/44937048830221-2026-Optimizely-CMS-13-release-notes"&gt;CMS 13 release notes (rolling)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="/link/5ffa0c0d7b184f049358e62d7fcad94f.aspx"&gt;Introducing the Optimizely CMS 13 Graph SDK&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="/link/232c025ff23340d5a2301c10eadf3ad7.aspx"&gt;A day in the life of an Optimizely OMVP (CMS 13)&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</summary></entry><entry><title>Running 64 Sites on Headless Optimizely CMS with GraphQL</title><link href="https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/3/running-64-sites-on-headless-optimizely-cms-with-graphql/" /><id>https://world.optimizely.com/blogs/piotr-nowak---optimizely--azure/dates/2026/3/running-64-sites-on-headless-optimizely-cms-with-graphql/</id><published>2026-03-14T17:57:22.0000000Z</published><updated>2026-03-14T17:57:22.0000000Z</updated><author><name>piotr.n@rockwool.com</name></author><category term="Blog post" /><summary type="html">&lt;p class="font-claude-response-body break-words whitespace-normal leading-[1.7]"&gt;64 websites. Live. Running on headless Optimizely with GraphQL.&lt;/p&gt;
&lt;p class="font-claude-response-body break-words whitespace-normal leading-[1.7]"&gt;We just wrapped a major rollout for our Rockwool Digital Experience Platform&amp;nbsp; and the early numbers already tell a story.&lt;/p&gt;
&lt;p class="font-claude-response-body break-words whitespace-normal leading-[1.7]"&gt;This wasn't about moving pixels. It was about rethinking how users actually interact with the brand. Here's what we're seeing so far:&lt;/p&gt;
&lt;ul class="[li_&amp;amp;]:mb-0 [li_&amp;amp;]:mt-1 [li_&amp;amp;]:gap-1 [&amp;amp;:not(:last-child)_ul]:pb-1 [&amp;amp;:not(:last-child)_ol]:pb-1 list-disc flex flex-col gap-1 pl-8 mb-3"&gt;
&lt;li class="whitespace-normal break-words pl-2"&gt;&lt;strong&gt;Product navigation is pulling its weight.&lt;/strong&gt; A leaner UI means visitors hit conversion pages faster.&lt;/li&gt;
&lt;li class="whitespace-normal break-words pl-2"&gt;&lt;strong&gt;Documentation is finally findable.&lt;/strong&gt; Doc downloads among active users have jumped noticeably.&lt;/li&gt;
&lt;li class="whitespace-normal break-words pl-2"&gt;&lt;strong&gt;Dealer pages are surging.&lt;/strong&gt; A smoother user journey created a much clearer path for low-touch leads.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;img src="/link/5e48ee1ba3064500b3054b1839543c44.aspx" width="697" height="366" /&gt;&lt;/p&gt;
&lt;p class="font-claude-response-body break-words whitespace-normal leading-[1.7]"&gt;&lt;strong&gt;What it took behind the scenes&lt;/strong&gt;&lt;/p&gt;
&lt;p class="font-claude-response-body break-words whitespace-normal leading-[1.7]"&gt;This was not a small lift. Making it work across 64 sites meant going deep into the stack:&lt;/p&gt;
&lt;ul class="[li_&amp;amp;]:mb-0 [li_&amp;amp;]:mt-1 [li_&amp;amp;]:gap-1 [&amp;amp;:not(:last-child)_ul]:pb-1 [&amp;amp;:not(:last-child)_ol]:pb-1 list-disc flex flex-col gap-1 pl-8 mb-3"&gt;
&lt;li class="whitespace-normal break-words pl-2"&gt;&lt;strong&gt;Custom scaling:&lt;/strong&gt; We extended Algolia queries and our Product Service to fully support sub-brands.&lt;/li&gt;
&lt;li class="whitespace-normal break-words pl-2"&gt;&lt;strong&gt;Independent pipelines:&lt;/strong&gt; Autonomous release processes and brand-specific Next.js configs keep things flexible.&lt;/li&gt;
&lt;li class="whitespace-normal break-words pl-2"&gt;&lt;strong&gt;Smart routing:&lt;/strong&gt; Azure Front Door handles the transition from legacy systems to the new Next.js environment.&lt;/li&gt;
&lt;li class="whitespace-normal break-words pl-2"&gt;&lt;strong&gt;The details:&lt;/strong&gt; Complex language mapping, domain consolidation, Inriver content tagging, massive redirect prep&amp;nbsp; all of it had to land.&lt;/li&gt;
&lt;/ul&gt;
&lt;p class="font-claude-response-body break-words whitespace-normal leading-[1.7]"&gt;The result? A platform that doesn't just look better - it actually performs. We've built a foundation where updates ship in days, not months.&lt;/p&gt;
&lt;p class="font-claude-response-body break-words whitespace-normal leading-[1.7]"&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class="font-claude-response-body break-words whitespace-normal leading-[1.7]"&gt;In the next article, I'd like to walk through the underlying architecture. I hope.&lt;/p&gt;</summary></entry><entry><title>64 Websites Live on Headless Optimizely with GraphQL</title><link href="https://pino-labs.com/blog/64-websites-headless-optimizely-graphql/" /><id>https://pino-labs.com/blog/64-websites-headless-optimizely-graphql/</id><published>2026-03-13T00:00:00.0000000Z</published><updated>2026-05-04T10:09:01.0000000Z</updated><author><name>piotr.n@rockwool.com</name></author><category term="Blog post" /><summary type="html">Major rollout for Digital Experience Platform complete. Product navigation working harder, documentation downloads up, dealer traffic growing.</summary></entry><entry><title>Moving Beyond "Lift and Shift": Lessons from an Optimizely DXP Migration</title><link href="https://pino-labs.com/blog/moving-beyond-lift-and-shift-dxp-migration/" /><id>https://pino-labs.com/blog/moving-beyond-lift-and-shift-dxp-migration/</id><published>2026-02-14T00:00:00.0000000Z</published><updated>2026-05-04T10:09:01.0000000Z</updated><author><name>piotr.n@rockwool.com</name></author><category term="Blog post" /><summary type="html">Successfully migrated from legacy Windows to Optimizely DXP Cloud-native Linux. Cross-platform compatibility, WAF rules, and Infrastructure as Code.</summary></entry><entry><title>Rockworld 2.0: A Strategic System Migration</title><link href="https://pino-labs.com/blog/rockworld-2-strategic-system-migration/" /><id>https://pino-labs.com/blog/rockworld-2-strategic-system-migration/</id><published>2025-06-14T00:00:00.0000000Z</published><updated>2026-05-04T10:09:01.0000000Z</updated><author><name>piotr.n@rockwool.com</name></author><category term="Blog post" /><summary type="html">Complex migration from legacy system to modern headless architecture using Optimizely Headless CMS, GraphQL, Next.js, and full Azure infrastructure.</summary></entry></feed>