A/B Testing with Vercel Edge Middleware

Vercel Edge Middleware lets you split traffic between two versions of a page before a single byte of HTML reaches the browser. Because the decision runs at the edge — in the same request that fetches the page — there is no client-side flash, no layout shift, and no flicker between control and variant. This guide walks through a production-grade A/B test: read or assign a sticky bucket cookie, hash the visitor into a stable bucket, rewrite the request to a variant path, set the cookie on the way out, and exclude bots and static assets so you do not pollute results or break caching.

By the end you will be able to:

  • Assign visitors to a control or variant bucket with a deterministic, sticky cookie that survives navigation.
  • Rewrite requests to /variant-b transparently so the URL in the address bar never changes.
  • Avoid duplicate-content SEO penalties and CDN cache collapse by configuring Vary and the route matcher correctly.
  • Verify the split with curl and debug the three failure modes that break most edge experiments.
Edge A/B test request flow A request enters the matcher, skips assets and bots, reads or assigns a cookie, hashes into a bucket, then rewrites to control or variant and sets the cookie. Incoming request matcher: skip assets / bots read ab cookie? assign + hash stable bucket control (A) serve as-is variant (B) rewrite /variant-b Set-Cookie: ab=… + Vary header

Prerequisites and environment setup

You need a Next.js application (App Router or Pages Router, version 13.1 or later) deployed on Vercel, with the Vercel CLI installed for local emulation:

npm install -g vercel@latest
vercel --version    # 33.x or newer
node --version      # v18.18+ recommended

Middleware lives in a single middleware.ts file at the project root (next to app/ or pages/, not inside them). It runs on Vercel’s Edge Runtime — a Web-standard environment with no Node APIs, so use crypto.subtle, fetch, and Request/Response rather than the node: modules. The Edge Runtime is the same execution model discussed in the Vercel Edge vs Cloudflare Workers performance comparison, so the latency budget here is single-digit milliseconds per request.

Create the two page variants first. The control is your normal route, for example app/pricing/page.tsx. The variant is a real, separately rendered page at app/variant-b/pricing/page.tsx. Keeping the variant on its own path means each version is a distinct render that the CDN can cache independently once you key the cache correctly.

Step-by-step: build the middleware

Every visitor carries an ab cookie holding their assigned bucket. On the first request the cookie is absent, so you assign one; on every subsequent request you reuse it. Stickiness is the whole point — a visitor who saw variant B must keep seeing variant B, or your conversion numbers are noise.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

const COOKIE = 'ab';
const VARIANT_PATH = '/variant-b';
const SPLIT = 0.5; // 50% to variant B

type Bucket = 'a' | 'b';

2. Hash for stable bucketing

Do not call Math.random() to assign new visitors. A hash of a stable per-visitor value gives you a deterministic bucket and lets you re-derive the same assignment if a cookie is ever lost. Generate a random visitor id once, hash it with SHA-256, and map the first bytes to the [0,1) range.

async function assignBucket(seed: string): Promise<Bucket> {
  const data = new TextEncoder().encode(seed);
  const digest = await crypto.subtle.digest('SHA-256', data);
  const bytes = new Uint8Array(digest);
  // Use the first 4 bytes as a uint32, normalize to 0..1
  const n = ((bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]) >>> 0;
  const ratio = n / 0xffffffff;
  return ratio < SPLIT ? 'b' : 'a';
}

Hashing also keeps the split honest as you scale the test across many concurrent users: the distribution converges on your SPLIT value instead of drifting.

Now wire it together. Read the cookie; if present, trust it. If absent, mint a seed, hash it, and remember the result. When the bucket is b, rewrite to the variant path — the browser keeps seeing /pricing while Vercel serves /variant-b/pricing. Finally, write the cookie and a Vary header on the response so caches separate the two variants.

export async function middleware(req: NextRequest) {
  const url = req.nextUrl;

  // Bot exclusion: keep crawlers on the canonical control to avoid SEO duplication.
  const ua = req.headers.get('user-agent') || '';
  const isBot = /bot|crawl|spider|slurp|bingpreview|facebookexternalhit/i.test(ua);

  let bucket = req.cookies.get(COOKIE)?.value as Bucket | undefined;
  let assigned = false;

  if (!bucket || (bucket !== 'a' && bucket !== 'b')) {
    if (isBot) {
      bucket = 'a'; // never bucket bots into the variant
    } else {
      const seed = crypto.randomUUID();
      bucket = await assignBucket(seed);
      assigned = true;
    }
  }

  // Build the response: rewrite for variant B, pass through for control.
  const res =
    bucket === 'b' && !url.pathname.startsWith(VARIANT_PATH)
      ? NextResponse.rewrite(new URL(`${VARIANT_PATH}${url.pathname}`, url))
      : NextResponse.next();

  if (assigned) {
    res.cookies.set(COOKIE, bucket, {
      path: '/',
      maxAge: 60 * 60 * 24 * 30, // 30 days
      sameSite: 'lax',
      httpOnly: false, // readable by client analytics
      secure: true,
    });
  }

  // Let caches and analytics distinguish the two renders.
  res.headers.set('Vary', 'Cookie');
  res.headers.set('x-ab-bucket', bucket);
  return res;
}

The x-ab-bucket response header is a debugging convenience you can strip in production; it makes curl verification trivial. Note the !url.pathname.startsWith(VARIANT_PATH) guard — without it a rewrite that re-enters middleware would prepend /variant-b repeatedly and loop.

4. Constrain the matcher

The config.matcher decides which requests run middleware at all. Excluding static assets, the image optimizer, and the variant path itself keeps the function off the hot path for files that must never be bucketed and prevents the rewrite from re-triggering middleware.

export const config = {
  matcher: [
    /*
     * Run on everything EXCEPT:
     *  - _next/static (build assets)
     *  - _next/image (image optimizer)
     *  - favicon.ico, robots.txt, sitemap.xml
     *  - the variant path itself (already rewritten)
     */
    '/((?!_next/static|_next/image|variant-b|favicon.ico|robots.txt|sitemap.xml).*)',
  ],
};

A tight matcher is also a cost and latency control: middleware that never fires on asset requests is middleware you do not pay for. The same discipline applies when modifying request headers at the CDN edge layer — scope the route before you scope the logic.

Verification

Deploy a preview and confirm the cookie and the served variant with curl. On the first request there is no cookie, so you expect a Set-Cookie and a bucket header:

curl -sI https://your-app.vercel.app/pricing | grep -iE 'set-cookie|x-ab-bucket|vary'
set-cookie: ab=b; Path=/; Max-Age=2592000; SameSite=Lax; Secure
x-ab-bucket: b
vary: Cookie

Now replay the request with that cookie and confirm stickiness — no new Set-Cookie, and the same bucket every time:

curl -sI -H 'Cookie: ab=b' https://your-app.vercel.app/pricing | grep -iE 'set-cookie|x-ab-bucket'
# x-ab-bucket: b   (no set-cookie line — sticky)

To prove the split is real, force both buckets and diff the rendered body. The variant should contain markup unique to app/variant-b/pricing/page.tsx:

curl -s -H 'Cookie: ab=a' https://your-app.vercel.app/pricing > a.html
curl -s -H 'Cookie: ab=b' https://your-app.vercel.app/pricing > b.html
diff <(grep -o 'data-variant="[^"]*"' a.html) <(grep -o 'data-variant="[^"]*"' b.html)

Finally confirm a crawler stays on control:

curl -sI -A 'Googlebot/2.1' https://your-app.vercel.app/pricing | grep -i x-ab-bucket
# x-ab-bucket: a

Troubleshooting

Symptom Likely cause Fix
Bucket flips on every reload Cookie never set or wrong attributes Confirm secure: true is served over HTTPS; check Path=/; assign only when assigned is true
Everyone sees the same variant CDN cached one render for all cookies Add Vary: Cookie and a variant-aware cache key
Request times out / 5xx loop Rewrite re-enters middleware Add the startsWith(VARIANT_PATH) guard and exclude variant-b in the matcher
Variant indexed in Google as duplicate Bots bucketed into B Force bots to control and emit a canonical tag

If the bucket changes between requests, the cookie either was not written or the browser rejected it. Over plain HTTP the Secure attribute drops the cookie silently — always test against an HTTPS preview URL. If you set the cookie on every request instead of only when assigned, you keep re-minting a fresh value; guard the cookies.set call exactly as shown above.

Caching collapses the variants

The most damaging failure: Vercel’s CDN (or a fronting CDN) caches the response for /pricing once and serves that single render to everyone, ignoring the cookie. Because the variants share a URL, you must split the cache key by the cookie. The Vary: Cookie header is the portable signal, but it is blunt — it varies on the entire cookie string, so any unrelated cookie shatters your hit ratio. For a precise key, normalize to just the ab value before caching, the same way you would when customizing cache keys to improve hit ratio. For dynamic, personalized pages the simplest safe path is to mark the variant routes non-cacheable with Cache-Control: private, no-store and accept the origin render.

Infinite rewrites

A 508-style loop or a hard timeout almost always means the rewrite target is itself matched by middleware, so /variant-b/pricing runs the rewrite again and produces /variant-b/variant-b/pricing. Two independent guards prevent this: the variant-b exclusion in the matcher regex, and the !url.pathname.startsWith(VARIANT_PATH) runtime check. Keep both — the matcher is your primary defense, the runtime check is a backstop for any path the regex misses.

Doing the same thing in Cloudflare Workers

If your stack already routes through Cloudflare, you can implement the identical pattern in a Worker: read the ab cookie from request.headers, hash with crypto.subtle, and return fetch() against a rewritten URL while appending a Set-Cookie header. The mechanics are the same; the differences are ergonomic. Vercel gives you NextResponse.rewrite and a typed config.matcher, whereas in a Worker you wire the routing and cookie logic by hand, as covered in deploying Cloudflare Workers for dynamic request routing. Cloudflare’s caches.default and Cache Rules give you finer control over the variant cache key, while Vercel’s tight Next.js integration makes the variant render trivial. Choose based on where your origin and CDN already live rather than raw capability.

Frequently Asked Questions

Why rewrite instead of redirect to the variant URL? A rewrite serves the variant content under the original URL, so the address bar, analytics, and shared links all stay canonical. A redirect would expose /variant-b/pricing, split your SEO signals across two URLs, and add a round trip.

Will A/B variants hurt my SEO? Not if you keep bots on the control path and add a rel="canonical" tag on the variant pointing at the control URL. The middleware above forces any request with a crawler user-agent into bucket a, so Google indexes one canonical render rather than two near-duplicates.

How do I run more than two variants? Replace the boolean split with cumulative thresholds: map the hashed ratio into ranges (for example < 0.33 → B, < 0.66 → C, else A) and store the chosen bucket label in the same cookie. Add a matching app/variant-c/ tree and a second rewrite branch.

Does the cookie need to be HttpOnly? Only if no client code reads it. The example sets httpOnly: false so browser analytics can report which bucket a session saw. If your analytics runs server-side or reads the x-ab-bucket header instead, set httpOnly: true to keep the value out of JavaScript.

How do I end the experiment cleanly? Pin SPLIT to 0 or 1 to drain traffic to the winning variant, redeploy, then promote the winning render into the canonical route and delete the variant path and middleware. Clearing the cookie via a one-line Set-Cookie: ab=; Max-Age=0 on the next deploy resets returning visitors.

Back to Vercel Edge Middleware