DEV Community

Jeremy Dorn
Jeremy Dorn

Posted on • Updated on

A/B Testing with the new Next.js 12 Middleware

Today, Vercel released Next.js 12 which adds a number of exciting performance improvements as well as a new beta feature - Middleware. Middleware has many uses, but I'm going to focus in this post on A/B Testing.

You've always been able to run A/B tests on Next.js applications, but until this latest release there have been some major strings attached. For example, on static pages, there would often be a "flash" where users would see the original page for a split second before your variation popped in and replaced it. And on server-rendered pages, you would need to completely disable caching since two users on the same URL could get two different HTML responses.

Next.js middleware fixes these issues in an elegant way. You can create two different pages for a single URL and route traffic between them with a middleware function. The middleware is run at the edge, so it's globally distributed and super fast for your users.

Let's start with a simple Next.js app structure:

pages/
|  index.tsx
|  index_new.tsx
|  _middleware.ts
lib/
|  abtesting.ts
Enter fullscreen mode Exit fullscreen mode

Our existing homepage (pages/index.tsx) is kind of boring:

/* pages/index.tsx */
import Link from "next/link"

export default function HomePage() {
  return <>
    <h1>My Site</h1>
    <Link href="/signup"><a>Sign Up</a></Link>
  </>
}
Enter fullscreen mode Exit fullscreen mode

We want to see if we can increase signups, so we clone the existing homepage into a new file (pages/index_new.tsx) and change the text to be more exiciting:

/* pages/index_new.tsx */
import Link from "next/link"

export default function HomePageVariant() {
  return <>
    <h1>Welcome to My Site!</h1>
    <Link href="/signup"><a>Get Started Today!</a></Link>
  </>
}
Enter fullscreen mode Exit fullscreen mode

Now the real magic happens in the pages/_middleware.ts file:

/* pages/_middleware.ts */
import { NextRequest, NextResponse } from 'next/server'
import { 
  initRequest,
  getExperimentContext,
  trackRequest
} from "../lib/abtesting";

export function middleware(req: NextRequest) {
  initRequest(req)

  // Default to normal Next.js routing
  let res = NextResponse.next()

  // If the user is on the homepage, run the experiment
  const { pathname } = req.nextUrl;
  if (pathname === "/") {
    const ctx = getExperimentContext(req)
    const { value: newPath } = ctx.run({
      key: "homepage-copy-experiment",
      variations: ["/", "/index_new"]
    })

    // Route to the assigned page path
    res = NextResponse.rewrite(newPath)
  }

  // Server-side analytics tracking
  trackRequest(req, res)

  return res
}
Enter fullscreen mode Exit fullscreen mode

The middleware file references a few helper functions in lib/abtesting.ts. For dependencies, I'm using GrowthBook to run the A/B tests and Mixpanel for analytics tracking. Feel free to swap these out with other libraries if you want.

/* lib/abtesting.ts */
import { GrowthBook } from "@growthbook/growthbook"
import { NextRequest, NextResponse } from "next/server"
import Mixpanel from "mixpanel"

const COOKIE_NAME = 'distinct_id'

const mixpanel = Mixpanel.init(process.env.MIXPANEL_TOKEN)

// Get the user's ip and distinct_id from the request
// Generate a new distinct_id if it doesn't exist yet
let distinct_id: string, ip: string
export function initRequest(req: NextRequest) {
  distinct_id = req.cookies[COOKIE_NAME] || crypto.randomUUID()
  ip = req.ip
}

// Function to get the GrowthBook A/B testing client
export function getExperimentContext(req: NextRequest) {
  return new GrowthBook({
    user: {id: distinct_id},
    trackingCallback: (experiment, result) => {
      mixpanel.track("Experiment Viewed", {
        distinct_id,
        ip,
        experimentId: experiment.key,
        variationId: result.variationId,
      })
    }
  })
}

export function trackRequest(req: NextRequest, res: NextResponse) {
  // Track the page view in Mixpanel
  mixpanel.track("Page View", {
    distinct_id,
    ip,
    path: req.nextUrl.pathname
  })

  // Persist the distinct_id in a cookie for future requests
  if (!req.cookies[COOKIE_NAME]) {
    res.cookie(COOKIE_NAME, distinct_id)
  }
}
Enter fullscreen mode Exit fullscreen mode

That's all we need! Now, when a user visits your site, the following will happen:

  1. If there is no distinct_id cookie yet, we generate a new UUID.
  2. If the user is requesting the homepage, we randomly assign them a variation - either / or /index_new - and overwrite the default Next.js routing to use the assigned path.
  3. We track the page view (and which variation the user was assigned) in Mixpanel.
  4. If we needed to generate a new UUID in step 1, we store it in a cookie in the response.

Hopefully in future releases, Next.js expands on this feature to make A/B testing even more powerful. Imagine, for example, that middleware could inject props into your pages, similar to getServerSideProps. Then you wouldn't need to create new temporary pages every time you wanted to run an A/B test!

Discussion (4)

Collapse
christopher2k profile image
Christopher N. KATOYI

This is huge. Thanks for sharing

Collapse
anishde12020 profile image
Anish De

Great article, keep it up!!!

Collapse
iamludal profile image
Ludal 🚀 • Edited on

Wow, I didn't even know this could be possible with Next.js! I'm so impressed by their updates. Thanks for sharing. 🤝

Collapse
monarch profile image
Prajjwal

Cool! and so quick post!!