Two ways to serve an A/B variant in the Next.js App Router
Server-side testing on Next.js lives or dies on one constraint: you have to pick the variant before the page renders, and picking it can't cost a network round trip. evolveAB makes that choice in the proxy (proxy.ts on Next.js 16, middleware.ts on 15.5), which reads a signed config bundle from memory, picks a variant for each running experiment and sets a cookie so the visitor keeps it. Our part takes well under a millisecond.
After that, the route's mode decides what happens. npx next-evolve init picks one of two for each route.
#Static mode
Static is the default, and it's the one you want for marketing pages. init moves the page file to app/[evolve]/<route>/page.tsx and wraps it with withEvolve(). From then on the proxy rewrites requests: a visit to /pricing becomes /<code>/pricing, where the code encodes which variant the visitor got in each experiment on that page.
Because the code sits in the path, Next.js treats every combination as its own page and caches each one with ISR, like any other static route. Control gets a cached render and so does variant B, so a visitor receives HTML that already contains their variant, straight from cache, with nothing left for the browser to swap.
SEO stays intact for two reasons. Direct requests to the internal /ev_… paths return 404, since those paths only exist as rewrite targets, and evolveMetadata() points each variant render's canonical URL at the original path and marks it noindex. Crawlers never see a variant anyway: bots such as search engines, AI crawlers, link unfurlers and Lighthouse always get control and no cookie, and we drop their events.
If you know the Flags SDK, this is its precompute pattern applied to experiments.
#Dynamic mode
Here the page file doesn't move. The proxy passes the assignment along in a request header and <Optimizable> reads it during render, so there's no rewrite and no cache entry per variant. The page renders on every request, which it was probably doing already.
init falls back to dynamic when moving the page could break something, and it prints the reason. The usual ones:
- a top-level dynamic segment such as
app/[slug], which would collide with[evolve] - a route group, or a layout the page shares with child routes
force-dynamic, or the page's owngenerateStaticParams- a page marked
"use client"
#Where cacheComponents fits
Since Next.js 16.4, new create-next-app projects ship with cacheComponents: true. That setting rejects the route segment config static mode depends on, so init switches every route to dynamic when it sees it.
You lose less than that sounds like. Wrap each <Optimizable> in <Suspense> and the rest of the page stays prerendered: the static shell comes from cache, and only the region under test streams in behind it. You give up the fully cached variant page and keep a fast first paint around the test.
#Choosing yourself
| Static | Dynamic | |
|---|---|---|
| Page file | Moved under app/[evolve]/ |
Stays put |
| Proxy does | Rewrites to a variant path | Sets a request header |
| Caching | One ISR render per variant | Rendered per request (or a Suspense region with cacheComponents) |
| Good for | Landing pages, pricing, home | Pages that are already dynamic, [slug] apps |
Let init decide, read the reason it gives, and override it only when you're sure the page is safe to move. The concepts page has the complete rules.
#About that millisecond
On self-hosted next start we budget 5 ms at p95 for our proxy code, and it measures between 0.1 and 0.35 ms. Vercel is a different story: running any proxy there adds roughly 20 ms to a first page view, and a proxy that does nothing at all measured 18 to 23 ms for us. That's the platform's price for executing code in front of the cache. Every tool that assigns variants on the server pays it, ours included, and quoting 0.35 ms on its own would hide it.