Skip to main content
Back to Blog
Tutorials
3 min read
December 9, 2024

How to Implement A/B Testing in Next.js with Edge Middleware

Set up cookie-based A/B testing using Next.js Edge Middleware for zero-flicker variant assignment and analytics tracking.

Ryel Banfield

Founder & Lead Developer

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.

A/B testingedge middlewareexperimentsNext.jstutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles