A/B Testing at the SSR Level — Cookie-Based Variant Selection

Eleventh in a series about migrating from legacy architectures to a modern Nuxt 4 stack.


Why A/B Testing in SSR Is Different

In a client-side SPA, A/B testing is simple: check a flag, choose a variant, render.

In SSR, the decision has to happen before the HTML is generated. The server renders the full page and sends it to the browser. If variant selection happens after the server renders A but the client expects B, hydration breaks (Article 9).

The variant must be fixed at the start of the SSR request, before any component renders. Every component must see the same assignment for the entire request.


The Architecture: Cookie-Based Variant Assignment

A common pattern uses a cookie (for example, abt) to persist variant assignments across page loads. The cookie stores a compact format: testId:variant,testId:variant,...

flowchart TD
  subgraph Step1["Step 1: First Visit (no cookie)"]
    A1["Browser<br/>Request: /homepage"] --> B1["Server<br/>(SSR entry)"]
    B1 --> C1["Read cookie #quot;abt#quot;<br/>Result: empty"]
    C1 --> D1["Fetch active A/B tests<br/>from headless CMS"]
    D1 --> E1["For each test:<br/>hash(userId + testId) → variant"]
    E1 --> F1["Set cookie:<br/>abt=pricing:A,hero:B"]
    F1 --> G1["Render HTML using<br/>pricing=A, hero=B"]
    G1 --> H1["Return HTML<br/>+ Set-Cookie header"]
    H1 --> I1["Browser receives HTML<br/>+ cookie persisting assignment"]
  end

  subgraph Step2["Step 2: Subsequent Visit (cookie exists)"]
    A2["Browser<br/>Request: /products<br/>Cookie: abt=pricing:A,hero:B"] --> B2["Server<br/>(SSR entry)"]
    B2 --> C2["Read cookie<br/>{pricing: A, hero: B}"]
    C2 --> D2["Render HTML using<br/>persisted variants"]
    D2 --> E2["No re-assignment —<br/>user sees consistent UX"]
  end

Why Cookies and Not Server-Side State

Cookies are the only mechanism that satisfies all constraints:

  1. Available during SSR — the server reads the cookie before rendering
  2. Persistent across page loads — the user sees the same variant consistently
  3. No server-side state — no session store, no database lookup, scales to any number of replicas
  4. Works with CDN caching — the cookie varies the cache key per variant when needed

CMS-Driven Test Configuration

In large enterprise applications, A/B tests are typically defined as entries in a headless CMS, not in application code. Each test entry might specify:

FieldExamplePurpose
testIdpricing-displayUnique identifier
variants["A", "B"]Available variants
trafficSplit[50, 50]Percentage per variant
startDate2025-06-01Test activation date
endDate2025-07-01Test deactivation date
targetPages["/products/*"]URL patterns where the test applies

Content editors create, update, and schedule tests without involving the development team. The application fetches active test configuration from the CMS at startup and caches it.

flowchart LR
  CMS["(Headless CMS)"] -->|"Define tests<br/>(testId, variants,<br/>trafficSplit, dates, pages)"| Conf[AB Test Config]
  Conf --> App["Nuxt App<br/>(on startup)"]
  App --> Cache[In-memory config cache]
  Cache --> SSR["SSR requests<br/>(variant assignment)"]

The Composable: useAbTest

Components consume A/B test variants through a composable:

sequenceDiagram
  participant C as Component
  participant U as useAbTest('pricing')
  participant S as SSR/Client Runtime
  participant ST as useState store

  C->>U: const { variant } = useAbTest('pricing')
  U->>S: Read current request's<br/>variant map
  S->>ST: Get variant map<br/>(from cookie / payload)
  ST-->>S: { pricing: 'A', ... }
  S-->>U: 'A'
  U-->>C: variant = 'A'

  Note over S,ST: On SSR: variant map is created<br/>before first component render

  Note over S,ST: On client: variant map is hydrated<br/>from Nuxt payload (no re-assignment)
Usage in a Vue component:
┌─────────────────────────────────────────────┐
│ const { variant } = useAbTest('pricing')    │
│                                             │
│ <template>                                  │
│   <PricingDisplayA v-if="variant === 'A'" />│
│   <PricingDisplayB v-else />                │
│ </template>                                 │
└─────────────────────────────────────────────┘

What happens under the hood:
  1. useAbTest reads the current request's
     variant map (from cookie or server state)
  2. Returns the assigned variant for this test
  3. On SSR: variant is determined before render
  4. On client: variant is hydrated from SSR state
     (no re-assignment, no flicker)

useAbTest is SSR-safe: it reads from useState (transferred from server to client via the Nuxt payload), so the client always sees what the server rendered.


URL-Based Override

During development and QA, testers need to force specific variants without waiting for random assignment.

A URL parameter overrides the cookie:

Normal visit:
  /products → variant from cookie (e.g., A)

Override:
  /products?abt=pricing:B → forces variant B for pricing test
  Cookie IS updated — the override persists across subsequent visits

QA Workflow:
  1. Test variant A: /products?abt=pricing:A → verify
  2. Test variant B: /products?abt=pricing:B → verify
  3. Compare → approve test
flowchart TD
  A["Browser<br/>/products?abt=pricing:B"] --> B[Server middleware]
  B --> C["Parse query param<br/>abt=pricing:B"]
  C --> D["Read existing cookie<br/>abt=pricing:A,hero:B"]
  D --> E["Merge override<br/>pricing → B"]
  E --> F["Set updated cookie<br/>abt=pricing:B,hero:B"]
  F --> G["SSR render using<br/>pricing=B, hero=B"]
  G --> H["Subsequent visits<br/>/products → use cookie<br/>(no query needed)"]

The override takes precedence and is merged into the cookie permanently. The server middleware merges the query parameter into existing assignments, and subsequent visits without the parameter continue using the overridden variant.


Debug Tooling

A non-production debug panel can show active tests and allow switching variants in real time:

flowchart LR
  Dev[QA / Developer] --> UI["Debug Panel<br/>A/B Tests Tab"]
  UI -->|Switch to A/B| Tool[set-ab-test tool]
  Tool --> Cookie[Update 'abt' cookie]
  Cookie --> Reload[Reload page]
  Reload --> SSR["SSR render<br/>with new variants"]

  SSR --> UI2["Debug Panel shows<br/>updated current variant"]
Debug Panel — A/B Tests Tab:
┌────────────────────────────────────────────────┐
│ Active A/B Tests                               │
│                                                │
│ ┌──────────────────────────────────────────┐   │
│ │ pricing-display                          │   │
│ │ Current: A                               │   │
│ │ [Switch to B] [Switch to A]              │   │
│ │ Traffic: 50/50                           │   │
│ │ Active: Jun 1 - Jul 1                    │   │
│ └──────────────────────────────────────────┘   │
│                                                │
│ ┌──────────────────────────────────────────┐   │
│ │ hero-layout                              │   │
│ │ Current: B                               │   │
│ │ [Switch to A] [Switch to B]              │   │
│ │ Traffic: 70/30                           │   │
│ │ Active: May 15 - Jun 30                  │   │
│ └──────────────────────────────────────────┘   │
│                                                │
│ Cookie: abt=pricing-display:A,hero-layout:B    │
└────────────────────────────────────────────────┘

Clicking “Switch to B” updates the cookie and reloads the page with the new variant. An internal debug chatbot can also switch variants programmatically via a set-ab-test tool.


Measuring Results

Variant assignments are tracked in analytics. Each page view includes the active variant assignments as custom properties:

Analytics Event:
  event: "page_view"
  properties:
    page: "/products"
    ab_pricing: "A"
    ab_hero: "B"
flowchart LR
  User[User page view] --> Page[Nuxt page]
  Page --> Analytics[Analytics SDK]
  Analytics --> Event["page_view event<br/>+ ab_* properties"]
  Event --> Store[Analytics backend]
  Store --> Seg["Analytics dashboard<br/>segment by variant"]
  Seg --> Decision["Decide winning variant<br/>(A or B)"]
  Decision --> CMS["(CMS config<br/>update default or<br/>deactivate test)"]

The analytics team segments conversion rates by variant. A typical workflow:

  1. Run the test for 2–4 weeks (enough data for statistical significance)
  2. Compare conversion rates per variant in the analytics dashboard
  3. If variant B wins: update the CMS to make B the default (100% traffic)
  4. If variant A wins: deactivate the test in the CMS (A was already the default)

No code changes are required either way. The lifecycle is entirely CMS-driven.


Cache Considerations

A/B testing interacts with page caching (Article 6). The cache key (for example, page-data:/products) does not include variant assignments.

This works because variants control which Vue component renders, not which CMS data is fetched. CMS data is the same regardless of variant — the variant only determines the component tree during SSR. Since the cache stores query results, not rendered HTML, the same data serves all variants.

flowchart LR
  A["Incoming request<br/>(with abt cookie)"] --> B[Cache layer]
  B -->|Key: page-data:/products| C[Cached CMS data]
  C --> D[SSR renderer]

  subgraph VariantA[User with variant A]
    D --> AComp["Render &lt;PricingDisplayA /&gt;<br/>using shared CMS data"]
  end

  subgraph VariantB[User with variant B]
    D --> BComp["Render &lt;PricingDisplayB /&gt;<br/>using shared CMS data"]
  end
Cache layer:
  Cache key: page-data:/products
  Cached: CMS data (shared across all variants)

A/B layer (runs after cache):
  Variant A user → CMS data → renders <PricingDisplayA />
  Variant B user → CMS data → renders <PricingDisplayB />

Variants must not trigger different CMS queries. If a future test requires variant-specific CMS content, the cache key would need to include variant assignments — but in many systems this is not necessary.


Lessons Learned

The variant decision must happen before SSR begins

This is the fundamental constraint. Any approach that determines the variant after the server starts rendering will cause hydration mismatches. The cookie must be read and the variant map populated before the first component’s setup() runs.

Cookies are the right persistence mechanism for SSR A/B tests

Server-side sessions add state. Local storage is unavailable during SSR. URL parameters are not persistent. Cookies are the only mechanism that is available on the server, persistent across requests, and stateless.

CMS-driven test configuration removes developer bottlenecks

When tests are defined in the CMS, marketing or product teams can create and schedule them independently. Development only needs to build the variant components.

Debug tooling for A/B tests is essential, not optional

Without a way to force a variant, QA has to clear cookies and hope for the right assignment. URL override and the debug panel make variant testing deterministic.


What’s Next

  • Article 20: CMS Live Preview — Real-Time WYSIWYG Editing in SSR — Making the CMS preview iframe work with server-side rendering.
  • Article 21: Structured Logging in Nuxt — From console.log to Production Observability — Multi-sink logging with runtime control.
  • Article 22: Building Production-Ready Nuxt Modules — Lifecycle, Hooks, and Conventions — The patterns behind reliable module development.

Munir Husseini is a software architect specializing in full-stack TypeScript, .NET, and cloud-native architectures.

Categories:

Leave a Reply

Your email address will not be published. Required fields are marked *