Learn
A/B testing in the Next.js App Router without forking routes
Most App Router tutorials either fork the route into two drifting trees or resolve on the client and bring back flicker. Apertio resolves a variant as typed data before first render, so one route serves every variant.
Most App Router A/B testing tutorials reach for one of two patterns, and both cost you something. The first forks the route: middleware rewrites each visitor to /page-a or /page-b, and now you maintain two trees that drift apart. The second resolves the variant on the client after hydration, which brings back the flicker you were trying to avoid and fights React’s own re-render.
Apertio resolves a variant as data before the first render of the instrumented component. The resolved { slotKey: value } map arrives as props or context, so there is no default-then-swap and no "before" state to flash from. Your route stays one tree. Your components stay your components. Apertio never touches the DOM.
Three stages: assignment, resolution, render
The mechanism splits into three stages, and keeping them separate is what makes each App Router rendering mode tractable.
Assignment decides which variant a visitor gets. It is deterministic: hash(unitId + experimentSalt) buckets the visitor, computed once and persisted in a cookie. Edge middleware is the right place to do this. Middleware cannot render React, but it can read or set the bucket cookie and pass it downstream in a header, so the server layer resolves with no extra round trip.
Resolution turns that assignment plus the experiment config into a concrete { slotKey: value } map. It is a pure function of assignment and config. The config itself is read from an in-memory snapshot the SDK refreshes in the background, so resolution does not make a network call on the request path.
Render is your app consuming that map. Apertio supplies values; your React components render them.
One route, one component tree
// slots.ts — the engineering/marketing contract, owned by engineering.
// Declared once, in code. Marketing tests within these bounds with no new code.
import { slot } from '@apertio/core'
export const slots = {
'home.hero.headline': slot.text({
label: 'Hero headline',
default: 'Ship experiments without the flicker',
maxLength: 60,
}),
'home.hero.cta': slot.text({
label: 'CTA copy',
default: 'Get started',
maxLength: 24,
}),
'home.hero.layout': slot.enum({
label: 'Hero layout',
options: ['centered', 'split', 'media-right'] as const,
default: 'centered',
}),
} // apertio.ts — bind the typed hooks to this app's slots, once.
// createApertio(slots) returns hooks whose return types come from `slots`.
import { createApertio } from '@apertio/react'
import { slots } from './slots'
export const { Provider, useSlot, useSlotState } = createApertio(slots) // app/page.tsx — one route, one component tree. No /page-a, no /page-b.
// The value is present at first paint during SSR or PPR, fully typed.
import { useSlot } from '../apertio'
export default function Hero() {
const headline = useSlot('home.hero.headline') // string, never undefined
const layout = useSlot('home.hero.layout') // 'centered' | 'split' | 'media-right'
return <HeroShell layout={layout}>{headline}</HeroShell>
} Declared once. Resolved server-side or in PPR. Fully typed at the call site.
No forked routes
One component tree resolves every variant. You do not maintain /page-a and /page-b that drift out of sync.
ISR stays usable
Cached HTML is shared across visitors, so per-visitor variants live in the dynamic boundary. Under PPR the static shell prerenders and caches while the slot is the dynamic hole, so the page stays cacheable and the slot still resolves per request.
Edge does the assignment
Middleware reads or sets the bucket cookie and forwards it in a header. The server resolves downstream with no extra round trip.
A mandatory default per slot
Every slot definition carries a default, so the render stage never blocks and never shows an empty state.
ISR, answered directly
ISR deserves a direct answer because it is where route-forking tutorials break. The cached HTML is shared across users, so a per-visitor variant cannot live inside it. You have two honest options. Edge-rewrite each visitor to a pre-rendered per-variant page, which works here because Apertio variants are bounded and therefore enumerable, but it is combinatorial and only fits when a variant is a small named bundle rather than independent slots multiplied together. Or resolve slots client-side after hydration, which reintroduces flicker. PPR avoids the choice: the static shell caches and the experimentable slot is the dynamic hole.
The honest limitation: only modeled slots are testable. A surface no one declared a slot for still needs an engineering change. The work shifts from building each test to instrumenting each new surface, and the aim is to keep that a small PR.
See it resolve server-side
Read how the SDK wires assignment, resolution, and render across every Next.js mode.
Frequently asked questions
- Does Next.js App Router A/B testing require forking my routes?
- No. Apertio resolves the variant as a typed data map before the component renders, so one route and one component tree serve every variant. You declare a slot once and marketing tests within it without new code.
- Can I A/B test with ISR in the App Router?
- Yes, with care. ISR caches HTML across all visitors, so a per-visitor variant cannot sit inside the cached HTML. Either edge-rewrite each visitor to a pre-rendered per-variant page, or use PPR so the static shell caches while the experimentable slot resolves per request as a dynamic hole.
- Where does variant assignment happen in Next.js?
- In edge middleware. Middleware cannot render React, but it reads or sets the deterministic bucket cookie and passes it downstream in a header, so the server layer resolves with no extra round trip.
- Will App Router A/B tests cause flicker?
- Not when resolved server-side or at the edge. The resolved value arrives as props before the first render of the instrumented component, so there is no default-then-swap to flash. Client-side resolution after hydration is the one mode that can flicker, and Apertio labels it as such.