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.
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 cost | Actual 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 HTML | Rarely mentioned | Real. The response now varies by cookie, so one URL no longer maps to one cacheable document |
| Per-request compute | Rarely mentioned | Real. Every matched request invokes the proxy function before anything is served |
| Blast radius | Not mentioned | Real. Bucketing code runs on every route the matcher covers, including routes with no experiment |
| Indexing | Rarely mentioned | Real. Two URLs serving the same intent is a duplicate-content problem unless you handle canonicals |
| Measurement | Assumed solved by the tool | Real. 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:
- Variant routes get
robots: { index: false }in theirgenerateMetadata, or they compete with the canonical page. - 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. - Do not put the variant in a query parameter if you can avoid it.
?variant=bis 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 rewrite | Server Component branch | Client-side flag | |
|---|---|---|---|
| Page stays prerendered | Yes, both variants | No — the page becomes dynamic if it reads a cookie | Yes |
| Shared CDN cache | Lost (varies by cookie) | Lost | Kept |
| Flash of wrong variant | None | None | Yes, unless handled |
| Blast radius of a bug | Every matched route | One route | One component |
| Works with no JS | Yes | Yes | No |
| Good for | Whole-page or layout tests | Tests needing server data to bucket | Copy, 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
| Mistake | Symptom | Fix |
|---|---|---|
| Bucketing without persisting | Conversion rates converge on identical numbers; no arm ever wins | Set the cookie on first assignment and read it thereafter |
| Experiment logic unguarded in the proxy | A thrown error takes out every route the matcher covers, not just the test | try/catch with a default variant; auth path untouched |
| Rewriting before a path guard | All 280 routes pay cookie-parse cost for a test on one page | Check pathname first and return early |
| Variant route left indexable | Two URLs competing for the same query in Search Console | robots: { index: false } plus a canonical to the public URL |
| Variant not attached to conversion events | You can see conversions rose but not which arm did it | Add the variant as a prop on every funnel event |
| Bot traffic bucketed randomly | Crawlers index a variant at random; rankings move for reasons you cannot trace | Always serve the control to known crawlers |
| Calling a test on three days of data | A "winner" that reverses next week | Fix 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.
