Skip to main content
Back to blog
Next.jsA/B TestingProxyPerformanceSSG

A static page that's secretly an A/B test

A/B testing a page is usually built so it falls off the CDN onto a per-request render. It doesn't have to be. Keep every variant fully static and let a proxy (Next.js's renamed middleware) resolve the assignment through an experimentation platform, keyed on a stable device id — then rewrite only when the visitor isn't in the control. The URL never changes, the CDN still caches, and the common path pays nothing.

Published August 14, 20268 min read

Every time you A/B test a page, the easy path quietly turns it dynamic — and you lose the CDN. You don't have to make that trade. Here's how to keep every variant static and hide the whole experiment behind a proxy.

The tension

You want to A/B test a page — pricing, a landing hero, an onboarding step. The moment you do, it usually stops being static:

  • Client-side A/B ships JavaScript, flashes the original variant first, and shifts layout. It also needs JS to work at all.
  • Render per request works, but every hit now goes to your origin — no CDN cache, worse TTFB, more compute.
  • Vary: Cookie looks tempting but fragments the cache into one entry per cookie value.

But the page is still, fundamentally, static HTML. The only dynamic thing is which static HTML — and whether it even differs from the default. Isolate exactly that.

The idea

  • Keep the control as the real, static page at /pricing.
  • Keep each non-control variant as its own static page under a dedicated route.
  • In a proxy, resolve the visitor's variant through an experimentation SDK. Control? Do nothing — the static page renders as-is. A variant? Rewrite to that variant's static page. Same URL either way.

The clever part is the asymmetry: most traffic is control, and control costs zero rewrites.

The stable identifier

Consistent bucketing needs a stable id, not a fresh coin flip per request. A long-lived device_id cookie is enough — the experimentation SDK hashes it (plus any targeting attributes), so the same device always lands in the same variant and the split stays at the weights you set.

That same id is your attribution key: every conversion can be traced back to the bucket the device was in.

The pages

The control is a normal static page. The variants live under a route nothing links to directly:

app/pricing/page.tsx
// The control — a plain static page.
export const dynamic = "force-static";

export default function Pricing() {
  return <Control />;
}
app/ab/pricing/[variant]/page.tsx
// Non-control variants, also static.
export const dynamic = "force-static";
export const dynamicParams = false; // only the variants you built

export function generateStaticParams() {
  return [{ variant: "b" }, { variant: "c" }];
}

export default async function Variant({
  params,
}: {
  params: Promise<{ variant: string }>;
}) {
  const { variant } = await params;
  return variant === "c" ? <VariantC /> : <VariantB />;
}

Control and each variant are prerendered and cached independently.

The proxy — the whole secret

proxy.ts
// proxy.ts  (this was middleware.ts before Next.js 16)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { ensureConfigReady, getSplitConfig } from "@/lib/experiments/config-cache";
import { resolveVariant } from "@/lib/experiments"; // wraps GrowthBook, Statsig, etc.

export const config = { matcher: "/pricing" };

export async function proxy(request: NextRequest) {
  // 1. Config first — a synchronous, in-memory lookup. No test here? Do nothing.
  await ensureConfigReady(); // no-op once warm
  const split = getSplitConfig("pricing");
  if (!split) return NextResponse.next();

  // 2. A stable id for consistent bucketing — reuse it, or mint one on first visit.
  let deviceId = request.cookies.get("device_id")?.value;
  const isNewDevice = !deviceId;
  if (!deviceId) deviceId = crypto.randomUUID();

  // 3. Ask the experimentation platform which variant this device is in.
  //    The SDK does the consistent hashing — no Math.random() here.
  const variant = await resolveVariant(split, {
    device_id: deviceId,
    country: request.headers.get("cf-ipcountry") ?? undefined,
    // locale, utm_source, … — whatever you target on
  });

  // 4. Control is the real page: render as-is. Only variants rewrite.
  const res =
    variant === "control"
      ? NextResponse.next()
      : NextResponse.rewrite(new URL(`/ab/pricing/${variant}`, request.url));

  // 5. Persist the id; drop a short-lived breadcrumb for analytics.
  if (isNewDevice) {
    res.cookies.set("device_id", deviceId, {
      path: "/",
      sameSite: "lax",
      maxAge: 60 * 60 * 24 * 365,
    });
  }
  res.cookies.set("ab_pricing", variant, {
    path: "/",
    sameSite: "lax",
    maxAge: 60 * 60, // an attribution breadcrumb, not the source of truth
  });
  return res;
}

On an older project this is middleware.ts with export function middleware — the same logic (proxy docs). Next.js ships a codemod to rename it: npx @next/codemod middleware-to-proxy .

The config the proxy runs on — cached in memory

The proxy can't magically know which pages are under test. That map — which page has an experiment, its key, each variant's slug — lives in your CMS. But you can't fetch it on every request (that puts the CMS in every page load's hot path), and the proxy runtime doesn't hand you the fetch cache you'd use in a Server Component. So you opt out with cache: "no-store" and cache it yourself, in memory:

lib/experiments/config-cache.ts
// Module scope, in-memory, no fetch cache.
type SplitConfig = { key: string; variants: { value: string; slug: string }[] };

const configByPage = new Map<string, SplitConfig>();
let ready = false;
let inflight: Promise<void> | null = null;

async function refresh() {
  // The proxy runtime has no usable fetch cache — opt out and cache ourselves.
  const res = await fetch(`${process.env.CMS_URL}/experiments`, { cache: "no-store" });
  const next = toConfigMap(await res.json());
  configByPage.clear(); // atomic-ish swap
  for (const [page, cfg] of next) configByPage.set(page, cfg);
  ready = true;
}

/** Rebuild — call this from a CMS webhook when experiment content changes. */
export async function refreshExperimentConfig() {
  try {
    await refresh();
  } catch {
    ready = true; // on failure, keep the old map and carry on
  }
}

/** Lazy single-flight init: the first caller fetches, the rest await it. */
export async function ensureConfigReady() {
  if (ready) return;
  if (!inflight) inflight = refreshExperimentConfig().finally(() => (inflight = null));
  await inflight;
}

/** Synchronous read — this is what the proxy calls on the hot path. */
export function getSplitConfig(page: string) {
  return configByPage.get(page);
}

Two things keep it fresh without a network hit on the hot path. Warm it once at boot in instrumentation.ts — its register() runs once and must finish before the server accepts requests, so the first visitor never pays for the fetch. And refresh it when content changes, not on a timer: point a CMS webhook at a route that rebuilds the map.

instrumentation.ts
// register() runs once at boot and must finish before the server serves.
export async function register() {
  const { refreshExperimentConfig } = await import("@/lib/experiments/config-cache");
  await refreshExperimentConfig();
}
app/api/experiments/refresh/route.ts
// Your CMS calls this whenever experiment content changes.
import { refreshExperimentConfig } from "@/lib/experiments/config-cache";

export async function POST() {
  await refreshExperimentConfig();
  return new Response("ok");
}

One caveat that bites at scale: this cache lives per instance. A single webhook only refreshes the container that received it — every other container and region still holds its own stale copy. In a multi-region deployment you have to fan the invalidation out to every instance so each proxy rebuilds — a broadcast to all regional deployments, or a pub/sub the instances subscribe to — plus a CDN purge for the affected URLs. That fan-out is an infra concern, not app code. No such plumbing? A short TTL on the cache bounds the staleness instead — simpler, just not instant.

Why an experimentation platform, not Math.random()

Rolling your own random in the proxy technically splits traffic, but you lose everything that makes an experiment trustworthy. A platform like GrowthBook (or Statsig, LaunchDarkly, Unleash) gives you:

  • Consistent hashing by device_id, so a visitor never flip-flops between variants.
  • Weights and gradual rollout you change without deploying — 50/50, a 5% canary, ramp to 100%.
  • Targeting on the attributes you pass (country, locale, UTM, plan), evaluated on the server.
  • A kill switch and one place to see every running experiment.

The proxy just asks the platform for a variant and rewrites. The experimentation logic lives where it belongs.

Why the cache still works

The CDN keys on the rewrite target. Control is /pricing (cached). Each variant is /ab/pricing/b, /ab/pricing/c (cached). The visitor sees /pricing throughout. No Vary: Cookie, no per-user fragmentation — and control, the majority path, isn't even rewritten.

Analytics

The short-lived ab_pricing cookie carries the resolved bucket. Read it wherever you emit events — server-side from the request, or client-side to tag your analytics — so every conversion is attributed to control, b or c. The page never leaves the CDN to make that happen.

Don't let it leak into SEO

  • Set a canonical of /pricing on the variant pages, so search consolidates on the real URL.
  • Keep /ab/* out of your sitemap and add noindex to it.
  • Have the SDK return control for crawlers (or when there's no device_id) — bots get one stable page.

The gotchas that cost an evening

  • Control does nothing. The control branch is NextResponse.next() — no rewrite, no variant page, no cost on the hot path. That asymmetry is the point.
  • Force it static. Mark the control and variant pages force-static so they're prerendered and cached; a stray dynamic API drops them to per-request rendering.
  • The proxy runs Node-first now (v16). Keep it lean — a cookie read, one SDK call, a rewrite — so it stays cheap wherever it's deployed.
  • Targeting comes from the request. Geo from the CDN header (cf-ipcountry), locale from the path, UTMs from the query — assemble the attributes in the proxy and hand them to the SDK.
  • Plan the exit. When a variant wins, fold it into /pricing, then delete the variant route and the experiment. An A/B test left running forever is tech debt with a cookie.

Takeaways

  • A page that's an A/B test can still be 100% static. The dynamic part is just which static page — and usually it's the control, which needs no work at all.
  • Resolve the assignment in the proxy through an experiment platform keyed on a stable device id — not a per-request random.
  • Rewrite only for non-control variants; the CDN caches control and each variant as its own page. No Vary: Cookie, no origin hit.
  • A device-id cookie gives sticky bucketing and attribution; a short breadcrumb cookie carries the variant to analytics.
  • Guard SEO with a canonical, noindex on the variant route, and control-for-bots — then tear the experiment down when it's done.

Spot a mistake?

A wrong fact, an off translation, something that reads false in this article? Tell me — in your own language.