Edge middleware for A/B testing headless content
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:
flowchart TD
Req["Request at edge"] --> Cookie{"Variant cookie set?"}
Cookie -->|yes| Use["Reuse assigned variant"]
Cookie -->|no| Hash["Deterministic hash of session id"]
Hash --> Set["Persist scoped cookie"]
Set --> Use
Use --> Inject["Inject x-ab-variant header"]
Inject --> Vary["Set Vary: x-ab-variant"]
Vary --> CMS["CMS fetch tagged by variant"]
CMS --> Render["Render isolated content path"]
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';
import crypto from 'crypto';
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';
function assignVariant(sessionId: string): 'control' | 'variant_b' {
// Deterministic assignment using SHA-256 hash of session ID
const hash = crypto.createHash('sha256').update(sessionId).digest('hex');
const hashInt = parseInt(hash.slice(0, 8), 16);
return hashInt % 2 === 0 ? 'control' : 'variant_b';
}
export async function middleware(req: NextRequest) {
const res = NextResponse.next();
// Retrieve or generate session identifier
let sessionId = req.cookies.get(SESSION_COOKIE_NAME)?.value;
if (!sessionId) {
sessionId = crypto.randomUUID();
res.cookies.set(SESSION_COOKIE_NAME, sessionId, {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 365,
});
}
// Retrieve or assign experiment variant
let variant = req.cookies.get(COOKIE_NAME)?.value;
if (!variant) {
variant = assignVariant(sessionId);
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,
});
}
// Inject headers for downstream routing and cache isolation
req.headers.set(VARIANT_HEADER, variant);
req.headers.set(SESSION_HEADER, sessionId);
// Ensure CDN respects variant-specific caching
res.headers.set('Vary', `${VARIANT_HEADER}, Cookie`);
return res;
}
The matcher excludes static assets and Next.js internals to cut overhead. 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.
Conclusion
Edge-side variant assignment eliminates the client-side hydration delay, the layout shift, and the cache fragmentation that client-only A/B testing causes. Deterministic assignment, scoped cookies, and strict cache segmentation give you statistically sound experiments that scale across global edge networks without a frontend performance cost.