The short answer: To A/B test in Next.js 14 App Router without flicker, use Edge Middleware to assign variants server-side. Set a cookie with the variant, then read it in your Server Components. This approach adds <5ms latency and zero Cumulative Layout Shift (CLS).
Who this is for
- Next.js developers using the App Router (Next.js 13.4+)
- Teams who need flicker-free A/B testing
- Performance-conscious developers (Core Web Vitals matter)
- Anyone using React Server Components
Who this is NOT for
- Pages Router users (see our Pages Router guide)
- Static export sites (Edge Middleware requires a server)
- Non-technical marketers (use a visual editor tool instead)
A/B Testing Approaches Compared
| Approach | Latency | Flicker | Complexity | Verdict |
|---|---|---|---|---|
| Edge Middleware (Recommended) | <5ms | None | Medium | Recommended |
| Client-Side SDK | 100-500ms | Yes (FOOC) | Low | — |
| Server Component Props | <10ms | None | High | — |
Prerequisites
- Next.js 13.4+ with App Router enabled
- Deployed to Vercel, Cloudflare, or any Edge-compatible platform
- Basic understanding of TypeScript and React Server Components
Step-by-Step Implementation
Create the Middleware
Create middleware.ts in your project root. This runs on the Edge before any page renders.
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
// Define your experiments
const EXPERIMENTS = {
'hero-headline': {
variants: ['control', 'variant-a', 'variant-b'],
weights: [0.34, 0.33, 0.33], // Traffic split
},
'pricing-layout': {
variants: ['control', 'horizontal'],
weights: [0.5, 0.5],
},
}
function getVariant(experimentId: string): string {
const experiment = EXPERIMENTS[experimentId as keyof typeof EXPERIMENTS]
if (!experiment) return 'control'
const random = Math.random()
let cumulative = 0
for (let i = 0; i < experiment.variants.length; i++) {
cumulative += experiment.weights[i]
if (random < cumulative) {
return experiment.variants[i]
}
}
return experiment.variants[0]
}
export function middleware(request: NextRequest) {
const response = NextResponse.next()
// Check for existing experiment cookies
for (const [experimentId] of Object.entries(EXPERIMENTS)) {
const cookieName = `exp_${experimentId}`
const existingVariant = request.cookies.get(cookieName)?.value
if (!existingVariant) {
// Assign new variant
const variant = getVariant(experimentId)
response.cookies.set(cookieName, variant, {
httpOnly: false, // Allow client-side reading for analytics
secure: true, // Use secure cookies in production
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
})
}
}
return response
}
export const config = {
matcher: [
// Match all paths except static files and API routes
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
}⚠️ Important: Edge Middleware runs on every request. Keep it fast—avoid database calls or heavy computation.
Create a Server-Side Helper
Create a utility to read experiment variants in Server Components.
// lib/experiments.ts
import { cookies } from 'next/headers'
export function getExperimentVariant(experimentId: string): string {
const cookieStore = cookies()
const variant = cookieStore.get(`exp_${experimentId}`)?.value
return variant || 'control'
}
// Type-safe variant getter
export function getHeroVariant(): 'control' | 'variant-a' | 'variant-b' {
return getExperimentVariant('hero-headline') as 'control' | 'variant-a' | 'variant-b'
}
export function getPricingVariant(): 'control' | 'horizontal' {
return getExperimentVariant('pricing-layout') as 'control' | 'horizontal'
}Use in Server Components
Read the variant in your Server Component and render accordingly.
// app/page.tsx
import { getHeroVariant } from '@/lib/experiments'
const headlines = {
'control': 'Build Better Products with A/B Testing',
'variant-a': 'Increase Conversions by 30% with Data-Driven Decisions',
'variant-b': 'Stop Guessing. Start Testing.',
}
export default function HomePage() {
const variant = getHeroVariant()
const headline = headlines[variant]
return (
<main>
<h1 className="text-4xl font-bold">
{headline}
</h1>
</main>
)
}Track Conversions
Send experiment data to your analytics platform when conversions happen.
// components/CTAButton.tsx
'use client'
function getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp('(^| )' + name + '=([^;]+)'))
return match ? match[2] : null
}
export function CTAButton() {
const handleClick = () => {
const variant = getCookie('exp_hero-headline') || 'control'
// Send to your analytics
fetch('/api/track', {
method: 'POST',
body: JSON.stringify({
event: 'cta_click',
experiment: 'hero-headline',
variant,
}),
})
// Or use ExperimentHQ
window.experimenthq?.track('cta_click', {
experiment: 'hero-headline',
variant,
})
}
return (
<button onClick={handleClick}>
Get Started Free
</button>
)
}Common Mistakes to Avoid
❌ Using Math.random() in Server Components
Server Components can render multiple times. Put randomization in middleware only, then read from cookies.
❌ Not persisting variants
Users should see the same variant across sessions. Use cookies with a 30-day expiry minimum.
❌ Caching variant-specific pages
Don't use static generation for pages with experiments. Use export const dynamic = 'force-dynamic' or ensure proper cache headers.
❌ Ignoring bot traffic
Filter out bots from your experiment data. Check user-agent in middleware and skip variant assignment for crawlers.
Performance Impact
<5ms
Added latency
0
CLS impact
~1KB
Bundle size
Edge Middleware runs in <50ms globally. Compare this to client-side A/B testing tools that add 100-500ms latency and cause visible layout shifts.
Our Recommendation
For Next.js App Router projects, Edge Middleware is the best approach for A/B testing. It's:
- Flicker-free — variants assigned before render
- Fast — <5ms latency at the edge
- SEO-safe — no CLS, no duplicate content issues
- RSC-compatible — works with Server Components
If you want a managed solution with visual editing, analytics, and statistical significance calculations, try ExperimentHQ — it integrates with Next.js in under 5 minutes.
Frequently Asked Questions
How do I prevent flicker in Next.js A/B tests?▼
Can I A/B test React Server Components?▼
cookies(). The variant is determined before React renders, so there's no hydration mismatch. Does A/B testing affect Next.js Core Web Vitals?▼
How do I handle caching with A/B tests?▼
export const dynamic = 'force-dynamic' to pages with experiments, or use the Vary: Cookie header to cache different variants separately.