Skip to main content
ASoc
Tutorial

A/B Testing in Next.js 16: The Proxy Recipe Costs the CDN, Not SSG

Rewriting in middleware does not make your pages dynamic — both variants stay prerendered. What it really costs is the shared cache and every request's critical path.

The ASoc Team11 min read

Almost every Next.js A/B testing tutorial lands on the same recipe: bucket the visitor in middleware, set a cookie, rewrite to a variant route. The recipe works, and the objection usually raised against it — that it forfeits static rendering — is wrong. Each variant stays prerendered. What it actually costs is the shared CDN cache and a place on the critical path of every request.

This site runs no A/B test today, so nothing below is a report on an experiment we ran. It is an architecture cost, priced against a codebase that already has the exact piece the recipe needs: a proxy that runs on every request. That turns out to be the expensive part, and it is not the part the tutorials price.

What the middleware recipe does and does not cost

Claimed costActual cost
Static rendering"Your pages become dynamic"No. /page-a and /page-b both stay prerendered; the rewrite only chooses between them
CDN caching of the HTMLRarely mentionedReal. The response now varies by cookie, so one URL no longer maps to one cacheable document
Per-request computeRarely mentionedReal. Every matched request invokes the proxy function before anything is served
Blast radiusNot mentionedReal. Bucketing code runs on every route the matcher covers, including routes with no experiment
IndexingRarely mentionedReal. Two URLs serving the same intent is a duplicate-content problem unless you handle canonicals
MeasurementAssumed solved by the toolReal. A stable bucket needs a persistent identifier, which is a consent question, not a code question

The first row is worth being precise about, because it is the most repeated wrong claim in this space. A rewrite in middleware picks which already-built page to serve. It is not the same mechanism as a CSP nonce, which is per-response and therefore does force dynamic rendering on every route it touches — that trade is covered in a Next.js CSP that keeps static rendering, and the full list of what silently opts a page out is in static rendering in the App Router. Rewriting is not on that list.

The proxy this codebase already has

In Next.js 16 the file is proxy.ts, not middleware.ts, and the exported function is proxy, not middleware — a rename with more edges than it looks, covered in Next.js 16 renamed middleware to proxy. Ours is 42 lines and exists for one reason: refreshing the Supabase session so Server Components see a valid one.

// src/proxy.ts
export async function proxy(request: NextRequest) {
  let response = NextResponse.next({ request });

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll: () => request.cookies.getAll(),
        setAll: (cookiesToSet) => {
          /* rotate auth cookies onto the response */
        },
      },
    },
  );

  await supabase.auth.getClaims();
  return response;
}

And the matcher, which is where the cost lives:

export const config = {
  matcher: [
    "/((?!_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|.*\\.(?:svg|png|jpg|jpeg|webp|gif|ico|webmanifest|html)$).*)",
  ],
};

That excludes static assets and the crawler routes and includes everything else — which is to say, every page. Our latest production build reports 272 pages prerendered and 8 dynamic routes (/api/download, /api/webhooks/lemonsqueezy, /auth/callback, /dashboard, /dashboard/settings, /login, /reset-password, /signup). The proxy runs ahead of all 280.

There is a documented hazard attached to that reach. This project's own CLAUDE.md carries a standing warning that the current main must not be deployed to production until the Supabase environment variables are set in Vercel, because proxy.ts runs on every request and will error site-wide without them. That is the real lesson for anyone adding experiment logic here: a bug in your bucketing function is not a broken experiment, it is a broken site. A component-level flag that throws breaks one component. A proxy that throws breaks everything the matcher covers.

Bucketing, if you do it there

The recipe itself is short. Read a cookie, assign one if absent, rewrite:

// The experiment half, kept deliberately separate from session refresh.
const EXPERIMENT = "pricing-headline";
const VARIANTS = ["a", "b"] as const;

function bucketFor(request: NextRequest): (typeof VARIANTS)[number] {
  const existing = request.cookies.get(`exp-${EXPERIMENT}`)?.value;
  if (existing === "a" || existing === "b") return existing;
  // Assign once, then persist — a re-roll on every request is not an experiment.
  return Math.random() < 0.5 ? "a" : "b";
}

Three constraints that the short version hides.

The bucket must be sticky, and stickiness means a cookie. Re-rolling per request gives every visitor a random mix of variants and a conversion number that measures nothing. Math.random() in the proxy is only the first assignment; the cookie is the experiment.

Wrap it so a failure cannot take the request down. In a proxy whose primary job is auth, the experiment must never be able to prevent NextResponse.next() from returning. That means a try/catch around the bucketing and a default variant on error — the same fail-open-on-non-essential posture, and the opposite of how the auth path should behave.

Keep the rewrite off routes that have no experiment. The matcher above covers 280 routes. Guard on pathname before doing anything, so 279 of them pay a string comparison rather than a cookie parse and a rewrite decision.

The half the tutorials skip: what Google sees

Rewriting /pricing to /pricing-b creates a second URL serving the same intent. If that URL is crawlable and indexable, you have built a duplicate-content problem on purpose, and this repo has already paid for a version of that mistake: 38 product pages sat in Search Console's "Discovered — currently not indexed" because of an internal-link-graph defect, the story published in migrating without losing rankings.

Three rules, in order of how much they matter:

  1. Variant routes get robots: { index: false } in their generateMetadata, or they compete with the canonical page.
  2. Both variants carry the same canonical, pointing at the public URL. A rewrite does not change the address bar, so the visitor is on /pricing — the markup should agree.
  3. Do not put the variant in a query parameter if you can avoid it. ?variant=b is a crawlable URL by default, and the facet-crawl-trap reasoning in product filtering in Next.js applies unchanged.

An experiment on a page that Google already ranks is also worth thinking about twice. The rewrite is invisible to the crawler if the bot is always bucketed into the control — which is the safe configuration, and one more thing your bucketing function has to get right rather than get random.

Measurement is the constraint, not the mechanism

Splitting traffic is the easy half. Attributing the outcome is where the architecture bites.

This site's conversion tracking is a deliberately tight typed union — one name per funnel step, so a typo cannot silently create a new event:

// src/lib/analytics.ts
type ConversionEvent =
  | "preview_opened"
  | "buy_clicked"
  | "waitlist_joined"
  | "blog_cta_clicked"
  | "wishlist_added";

Adding an experiment means every one of those events needs the variant attached as a prop, or you can see that conversions happened but not which arm produced them — the same per-session-versus-per-thing problem that made blog_cta_clicked carry both a post slug and a target slug rather than firing bare.

The harder constraint is that this stack runs cookieless analytics on purpose, which is what lets it ship without a consent banner. An A/B test needs a persistent per-visitor identifier to hold a bucket across a session. Set that cookie and the cookieless claim is no longer true, and the reasoning in Vercel Analytics vs Google Analytics 4 — that a banner-gated tool measures only the subset of visitors who agreed to be measured — starts applying to your experiment data too. A sampled experiment is not a smaller experiment; it is an experiment on a self-selected population.

The three places to run a test, honestly

Proxy rewriteServer Component branchClient-side flag
Page stays prerenderedYes, both variantsNo — the page becomes dynamic if it reads a cookieYes
Shared CDN cacheLost (varies by cookie)LostKept
Flash of wrong variantNoneNoneYes, unless handled
Blast radius of a bugEvery matched routeOne routeOne component
Works with no JSYesYesNo
Good forWhole-page or layout testsTests needing server data to bucketCopy, colour, small components

For a test on one headline or one button, the client-side flag is usually the right answer and the proxy is over-engineering — the cost is a possible flash of the control, which a small inline decision before paint avoids. Reach for the proxy when the variants differ structurally enough that shipping both to the browser is the bigger problem, or when the test must survive JavaScript being off.

For a marketing site whose whole performance story rests on prerendered HTML served from a CDN, there is a fourth option worth naming: run the test on a page that is already dynamic. Of the 8 dynamic routes in the census above, several are exactly the conversion surfaces you would want to experiment on anyway.

Mistakes and how they show up

MistakeSymptomFix
Bucketing without persistingConversion rates converge on identical numbers; no arm ever winsSet the cookie on first assignment and read it thereafter
Experiment logic unguarded in the proxyA thrown error takes out every route the matcher covers, not just the testtry/catch with a default variant; auth path untouched
Rewriting before a path guardAll 280 routes pay cookie-parse cost for a test on one pageCheck pathname first and return early
Variant route left indexableTwo URLs competing for the same query in Search Consolerobots: { index: false } plus a canonical to the public URL
Variant not attached to conversion eventsYou can see conversions rose but not which arm did itAdd the variant as a prop on every funnel event
Bot traffic bucketed randomlyCrawlers index a variant at random; rankings move for reasons you cannot traceAlways serve the control to known crawlers
Calling a test on three days of dataA "winner" that reverses next weekFix the sample size and duration before starting, not after looking

Frequently asked questions

Does middleware A/B testing break static rendering in Next.js? No. Both variant routes stay prerendered; the rewrite selects between two already-built pages. What it breaks is the ability to serve one cached document for one URL, because the response now depends on a cookie. That is a CDN cost, not a rendering-mode change — and it is a real cost, just not the one usually cited.

Can I A/B test without any cookie at all? Not with a sticky bucket. You can split by something already present — a path, a country header, an existing session — but a randomly-assigned bucket has to be stored somewhere to persist, and in the proxy the only place is a cookie. If you cannot set one, split on an attribute you already have rather than pretending randomness will hold.

Where should the flag live if I only want to test copy? In the component, behind a small client-side check. A proxy rewrite for a headline change puts every request through experiment code to alter a string, and gives up the shared CDN cache to do it.

Do I need a third-party experimentation platform? Not to run one test — a cookie, a rewrite and an event property will run it. You need one when you have several concurrent tests, need holdout groups, or want the statistics computed for you. The failure mode of rolling your own is almost never the splitting; it is calling the result too early.

Templates with the conversion surfaces worth testing

ASoc Apex Admin spans 5 dashboards across 115+ pages including a full ecommerce back office, so it has the funnel depth where an experiment has somewhere to show up. ASoc Clover Admin is CRM-shaped — deals, customers, activities — which is where a variant's downstream effect actually gets measured rather than guessed. ASoc Vertex Admin is a densely-built ecommerce dashboard with store-performance analytics and a sales report, the reporting surface an experiment result eventually has to land on.

Browse the full set of React admin templates, Next.js admin templates, or Tailwind admin templates.

Keep reading

Tutorial11 min read

An Accessible Mega Menu in Next.js Without a Headless UI Library

It is a navigation landmark, not an application menu — and the ARIA menu pattern most tutorials copy removes your nav from every screen reader's link list. Six behaviours, eighty lines.

Read more
Tutorial12 min read

How to Build an Admin Dashboard with Next.js 16 and Tailwind CSS v4

A working admin dashboard in Next.js 16 and Tailwind CSS v4 — App Router layouts, a CSS-first theme, an accessible sidebar, and the server/client split that keeps it fast.

Read more
Tutorial10 min read

Next.js on AWS: Amplify, Lambda, ECS or EC2 — 8 Routes Decide It

Amplify, Lambda via SST, ECS Fargate, or EC2 — AWS has no framework-aware default. This build's own 428 static and 8 Node-runtime dynamic routes decide which compute layer actually fits.

Read more