A/B testing lets you measure which variant of a page converts better. Using Edge Middleware, you can assign variants before the page renders — no flicker.
Define Experiments
// lib/experiments.ts
export interface Experiment {
id: string;
variants: { id: string; weight: number }[];
}
export const experiments: Record<string, Experiment> = {
"homepage-hero": {
id: "homepage-hero",
variants: [
{ id: "control", weight: 50 },
{ id: "variant-a", weight: 50 },
],
},
"pricing-layout": {
id: "pricing-layout",
variants: [
{ id: "control", weight: 34 },
{ id: "cards", weight: 33 },
{ id: "table", weight: 33 },
],
},
};
export function pickVariant(experiment: Experiment): string {
const random = Math.random() * 100;
let cumulative = 0;
for (const variant of experiment.variants) {
cumulative += variant.weight;
if (random < cumulative) return variant.id;
}
return experiment.variants[0]?.id ?? "control";
}
Edge Middleware for Variant Assignment
// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { experiments, pickVariant } from "./lib/experiments";
const COOKIE_PREFIX = "exp_";
export function middleware(request: NextRequest) {
const response = NextResponse.next();
for (const [key, experiment] of Object.entries(experiments)) {
const cookieName = `${COOKIE_PREFIX}${key}`;
const existingVariant = request.cookies.get(cookieName)?.value;
// Validate existing variant
const validVariantIds = experiment.variants.map((v) => v.id);
const variant =
existingVariant && validVariantIds.includes(existingVariant)
? existingVariant
: pickVariant(experiment);
if (!existingVariant || existingVariant !== variant) {
response.cookies.set(cookieName, variant, {
maxAge: 60 * 60 * 24 * 30, // 30 days
httpOnly: false, // Needs to be readable by client JS
sameSite: "lax",
path: "/",
});
}
// Set header so server components can read the variant
response.headers.set(`x-experiment-${key}`, variant);
}
return response;
}
export const config = {
matcher: ["/", "/pricing"],
};
Read Variants in Server Components
// lib/get-variant.ts
import { cookies } from "next/headers";
export async function getVariant(experimentId: string): Promise<string> {
const cookieStore = await cookies();
return cookieStore.get(`exp_${experimentId}`)?.value ?? "control";
}
Use Variants in Pages
// app/(site)/page.tsx
import { getVariant } from "@/lib/get-variant";
function HeroControl() {
return (
<section className="py-20 text-center">
<h1 className="text-4xl font-bold">Build Your Website Today</h1>
<p className="text-muted-foreground mt-4">
Professional web development for modern businesses
</p>
<a href="/contact" className="mt-6 inline-block px-6 py-3 bg-primary text-primary-foreground rounded-md">
Get Started
</a>
</section>
);
}
function HeroVariantA() {
return (
<section className="py-20 text-center">
<h1 className="text-4xl font-bold">Launch in Weeks, Not Months</h1>
<p className="text-muted-foreground mt-4">
Ship fast with our expert development team
</p>
<a href="/contact" className="mt-6 inline-block px-6 py-3 bg-green-600 text-white rounded-md">
Start Free Consultation
</a>
</section>
);
}
export default async function HomePage() {
const heroVariant = await getVariant("homepage-hero");
return (
<main>
{heroVariant === "variant-a" ? <HeroVariantA /> : <HeroControl />}
{/* rest of page */}
</main>
);
}
Track Conversions
"use client";
import { useEffect } from "react";
function getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
return match ? decodeURIComponent(match[1]) : null;
}
export function ExperimentTracker() {
useEffect(() => {
const experiments: Record<string, string> = {};
document.cookie.split("; ").forEach((cookie) => {
const [key, value] = cookie.split("=");
if (key?.startsWith("exp_") && value) {
experiments[key.replace("exp_", "")] = decodeURIComponent(value);
}
});
// Send to your analytics
if (typeof window.gtag === "function") {
for (const [experiment, variant] of Object.entries(experiments)) {
window.gtag("event", "experiment_viewed", {
experiment_id: experiment,
variant_id: variant,
});
}
}
}, []);
return null;
}
export function trackConversion(experimentId: string, eventName: string) {
const variant = getCookie(`exp_${experimentId}`);
if (!variant) return;
if (typeof window.gtag === "function") {
window.gtag("event", eventName, {
experiment_id: experimentId,
variant_id: variant,
});
}
// Also send to your backend
fetch("/api/experiments/track", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ experimentId, variant, event: eventName }),
}).catch(() => {});
}
API Route for Results
// app/api/experiments/results/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { experimentEvents } from "@/db/schema";
import { eq, and, count } from "drizzle-orm";
export async function GET(request: NextRequest) {
const experimentId = request.nextUrl.searchParams.get("experiment");
if (!experimentId) {
return NextResponse.json({ error: "Missing experiment ID" }, { status: 400 });
}
const results = await db
.select({
variant: experimentEvents.variant,
event: experimentEvents.event,
count: count(),
})
.from(experimentEvents)
.where(eq(experimentEvents.experimentId, experimentId))
.groupBy(experimentEvents.variant, experimentEvents.event);
// Calculate conversion rates
const variants: Record<string, { views: number; conversions: number; rate: number }> = {};
for (const row of results) {
if (!variants[row.variant]) {
variants[row.variant] = { views: 0, conversions: 0, rate: 0 };
}
if (row.event === "view") {
variants[row.variant].views = row.count;
} else {
variants[row.variant].conversions += row.count;
}
}
for (const v of Object.values(variants)) {
v.rate = v.views > 0 ? (v.conversions / v.views) * 100 : 0;
}
return NextResponse.json({ experimentId, variants });
}
Need Conversion Optimization?
We build data-driven websites with A/B testing, analytics, and conversion optimization built in. Contact us to improve your conversion rates.