Edge Middleware for A/B Testing Headless Content
As part of Content Delivery Network Routing Logic, this guide moves experiment assignment to the edge. Client-side A/B frameworks assign the variant after hydration, which costs a layout shift, a delayed render, and an extra JavaScript payload. Moving assignment to edge middleware fixes all three: it intercepts the request before origin, picks a deterministic variant, and routes to an isolated content path without breaking CDN cache efficiency or hydration.
Deterministic Request Interception at the Network Boundary
Edge middleware runs at the CDN before the request reaches your app server or CMS. It checks for an experiment cookie; if absent, it assigns a variant from a stable hash (or weighted random), persists it in a scoped cookie, and injects it into request headers for downstream use. The same user resolves to the same variant from any edge node, and the client never sees the routing decision.
Variant assignment and cache segmentation at the edge:
Cookie hygiene matters: set SameSite=Lax against CSRF and apply Secure in production. The middleware must run before any cache lookup, or returning users get a stale assignment.
Production-Ready Edge Middleware Implementation
A Next.js App Router middleware handling assignment, cookie persistence, and header injection — no synchronous blocking on the hot path:
import { NextRequest, NextResponse } from 'next/server';
export const config = {
// Exclude static assets, API routes, and Next.js internals
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|robots.txt).*)'],
};
const COOKIE_NAME = 'ab_experiment_variant';
const SESSION_COOKIE_NAME = 'ab_session_id';
const VARIANT_HEADER = 'x-ab-variant';
const SESSION_HEADER = 'x-ab-session-id';
async function assignVariant(sessionId: string): Promise<'control' | 'variant_b'> {
// Deterministic assignment from a SHA-256 hash of the session id (Web Crypto: edge-safe)
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(sessionId));
const hashInt = new DataView(digest).getUint32(0);
return hashInt % 2 === 0 ? 'control' : 'variant_b';
}
export async function middleware(req: NextRequest) {
const cookieSession = req.cookies.get(SESSION_COOKIE_NAME)?.value;
const cookieVariant = req.cookies.get(COOKIE_NAME)?.value;
const sessionId = cookieSession ?? crypto.randomUUID();
const variant = cookieVariant ?? (await assignVariant(sessionId));
// Pass the variant to server components through request headers.
const forwarded = new Headers(req.headers);
forwarded.set(VARIANT_HEADER, variant);
forwarded.set(SESSION_HEADER, sessionId);
const res = NextResponse.next({ request: { headers: forwarded } });
if (!cookieSession) {
res.cookies.set(SESSION_COOKIE_NAME, sessionId, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365,
});
}
if (!cookieVariant) {
res.cookies.set(COOKIE_NAME, variant, {
path: '/',
httpOnly: false, // Readable by client for telemetry
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30,
});
}
// Ensure caches keep variant responses apart
res.headers.set('Vary', `${VARIANT_HEADER}, Cookie`);
return res;
}
The matcher excludes static assets and Next.js internals to cut overhead. The hash uses the Web Crypto API, which is available in the edge runtime where Node’s crypto module is not, and the variant reaches server components through NextResponse.next({ request: { headers } }), because headers set on the incoming request object are not forwarded. Assignment is a deterministic hash of the session ID, so routing stays consistent across edge nodes, and Vary tells the CDN to segment cached responses by the experiment header.
Cache Key Isolation and CDN Routing
The killer failure mode is the CDN collapsing variant responses into one cache key — users get mismatched content on a hit, corrupting both metrics and UX. Vary makes the CDN treat distinct x-ab-variant values as separate entries; for finer control, append the variant to the cache key via URL rewriting or surrogate keys.
This is standard Content Delivery Network Routing Logic. When the edge rewrites to /api/cms?variant=control, the downstream fetch must return distinct cache tags. Pairing Cache-Control: s-maxage=3600, stale-while-revalidate=86400 with variant-specific Surrogate-Key headers enables targeted invalidation — content updates reach active experiments immediately while unaffected routes keep their hit ratio.
Headless CMS Fetch Integration
With the variant in the request headers, the data-fetching layer reads it and appends it as a query parameter or GraphQL variable to route to the right content source:
// lib/cms-client.ts
import { headers } from 'next/headers';
export async function fetchCMSContent(path: string) {
const headersList = await headers();
const variant = headersList.get('x-ab-variant') || 'control';
const response = await fetch(`${process.env.CMS_BASE_URL}/api/content`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path,
variant,
locale: 'en-US',
}),
next: {
tags: [`cms:${path}`, `variant:${variant}`],
revalidate: 3600,
},
});
if (!response.ok) throw new Error(`CMS fetch failed: ${response.status}`);
return response.json();
}
Embedding the variant in the payload and using Next.js revalidate tags keeps experiment branches separate and aligned with the broader Data Fetching & Caching Strategies — no cache poisoning, and editors can preview variant layouts without a global purge.
Preventing React Hydration Mismatches
Hydration fails when server markup diverges from the client. Resolve the variant asynchronously on the client and the first render won’t match the server’s output — hydration warning, forced re-render. Inject the variant into the initial payload or expose it as a synchronous global.
Serialize it into a <script> in the root layout so client components read it synchronously at mount:
// app/layout.tsx
import { headers } from 'next/headers';
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const headersList = await headers();
const variant = headersList.get('x-ab-variant') || 'control';
return (
<html lang="en">
<head>
<script
dangerouslySetInnerHTML={{
__html: `window.__AB_VARIANT__ = '${variant}';`,
}}
/>
</head>
<body>{children}</body>
</html>
);
}
Read window.__AB_VARIANT__ at init — don’t re-fetch the variant via useEffect. The synchronous read keeps the client DOM matching the server’s, killing the mismatch and protecting Core Web Vitals.
Validation and Automated Testing
Validate routing accuracy, cache isolation, and metric integrity. Use Playwright or Cypress to simulate edge requests across cookie states and assert the correct x-ab-variant lands in headers and responses. Issue identical requests with different variant cookies and confirm response bodies and cache keys stay isolated. Contract-test the CMS client so variant parameters serialize correctly and missing headers fall back to control. See Automated Testing for Headless Integrations.
Configuration Reference
| Setting | Value | Why |
|---|---|---|
| Variant cookie lifetime | experiment duration, max 30 days | Stable assignment for the whole test. |
| Session cookie | httpOnly, SameSite=Lax, Secure |
Used only for hashing, never read by scripts. |
| Assignment | hash of session id modulo buckets | Deterministic across edge nodes and deploys. |
| Cache separation | variant in path or custom key | Keeps variants in separate entries. |
| CMS tags | cms:path, variant:name |
Invalidates one variant without touching the other. |
Gotchas & Edge Cases
- Variant swap on the first request. New visitors are assigned in the same request that renders the page, so the first response must already use the assigned variant, as the corrected middleware does by reading the local variable instead of the cookie.
- Bots and crawlers. Search engines should see the control variant consistently. Assign known crawler user agents to control, or search results may show variant copy.
- Sample ratio mismatch. If caching or redirects drop some variant requests, the split drifts from 50/50 and invalidates the experiment. Compare assignment counts with exposure counts daily.
- Editors previewing variants. Give editors a query parameter or preview cookie that forces a variant in preview mode only, so they can review both versions without clearing cookies.
Worked Example
A SaaS company tested two hero messages on its pricing page with a client-side tool. The variant swapped in about 400 ms after hydration, which shifted the plan cards and pushed the page’s CLS into the “poor” band, and the test’s conversion data was muddied by readers who clicked before the swap. Moving assignment to edge middleware, with variant content modeled as two hero entries in the CMS and the page cached per variant, removed the swap. CLS returned to 0.02, LCP improved by roughly a second at the 75th percentile, and the experiment ran to significance a week sooner because every visitor saw a stable page.
Frequently Asked Questions
How should variants be modeled in the CMS?
As separate entries or a variant field on the block being tested, with the experiment id recorded on each variant. Avoid duplicating whole pages; test the smallest block that carries the hypothesis, so editors maintain one page with one alternate block.
Does edge assignment work with ISR pages?
Yes, if each variant is a separate cacheable path or key. The middleware rewrites to a variant path that ISR caches independently, so both variants stay static and fast.
How do I end an experiment cleanly?
Promote the winning variant to the default content in the CMS, remove the middleware rule, and purge the variant tags. Keep the cookie-reading code tolerant of the old cookie for a few days, so visitors with stale cookies still get the default page.
Can I run several experiments at once?
Yes, with one cookie per experiment or a single cookie holding a compact map of experiment ids to buckets. Hash each experiment with its own salt so assignments are independent, and keep the number of concurrent experiments on one page small, because every experiment multiplies the cached variants of that page.
Where should exposure events be logged?
Log exposure when the variant is actually rendered, from the client, with the variant read from the synchronous global. Logging at assignment time in middleware counts visitors who never saw the tested block, which dilutes the result.