How to set up Next.js A/B tests
Feb 16, 2024
A/B tests are a way to make sure the content of your Next.js app performs as well as possible. They compare two or more variations on their impact on a goal.
PostHog's experimentation tool makes it easy to set up A/B tests. This tutorial shows you how to build a basic Next.js app (with the app router), add PostHog to it, bootstrap feature flag data, and set up the A/B test in the app.
1. Create a Next.js app
We will create a basic Next.js app with a simple button to run our test on. First, make sure Node is installed (18.17 or newer), then create a Next.js app:
npx create-next-app@latest next-ab
Select No
for TypeScript, Yes
for use app router
, and the defaults for the rest of the options. Once created, go to your app/page.js
file and set up a basic page with a heading and a button.
// app/page.jsexport default function Home() {return (<main><h1>Next.js A/B tests</h1><button id="main-cta">Click me</button></main>);}
Once done, run npm run dev
and go http://localhost:3000
to see your app.
2. Add PostHog
To track our app and set up an A/B test, we install PostHog. If you don't have a PostHog instance already, you can sign up for free.
Start by installing the posthog-js
SDK:
npm i posthog-js
Next, create a providers.js
file in your app
folder. In it, initialize PostHog with your project API key and instance address and export a provider component.
// app/providers.js'use client'import posthog from 'posthog-js'import { PostHogProvider } from 'posthog-js/react'export function PHProvider({ children }) {if (typeof window !== 'undefined') {posthog.init('<ph_project_api_key>', {api_host: 'https://us.i.posthog.com'})}return <PostHogProvider client={posthog}>{children}</PostHogProvider>}
Once created, you can import PHProvider
into your layout.js
file and wrap your app in it:
import "./globals.css";import { PHProvider } from './providers'export default function RootLayout({ children }) {return (<html lang="en"><PHProvider><body>{children}</body></PHProvider></html>);}
When you reload your app, you should see events captured in your activity tab in PostHog.
3. Creating an action for our experiment goal
To measure the impact of our change, we need a goal metric. To set this up, we can create an action from the events PostHog autocaptures using the toolbar.
To enable and launch the toolbar, go to the "Launch toolbar" tab, add http://localhost:3000/
as an authorized URL, then click launch. To create an action with the toolbar, click:
- The target icon (inspector) in the toolbar
- The "Click me" button
- "Create new action" in the modal
Name the action "Clicked Main CTA" and then click "Create action."
Note: You can also use a custom event as a goal metric. See our full Next.js analytics tutorial for how to set up custom event capture.
4. Creating an experiment
With PostHog installed and our action set up, we're ready to create our experiment. To do so, go to the A/B testing tab in PostHog, click "New experiment," and then:
- Name your A/B test.
- Set your feature flag key to something like
main-cta
. - Choose the "Clicked Main CTA" action as your experiment goal. You may need to refresh for it to show up.
- Click "Save as draft" and then "Launch."
Once done, you're ready to go back to your app to start implementing it.
5. Bootstrapping feature flags
A/B testing in PostHog relies on feature flag data. To ensure that feature flag data is available as soon as possible, we make a server-side request for it and then pass it to the client-side initialization of PostHog (known as bootstrapping).
Set up a function in layout.js
that:
- Checks for the user
distinct_id
in the cookies. - If it doesn't exist, creates one using
uuidv7
. - Uses the
posthog-node
library and thedistinct_id
togetAllFlags
. - Passes the flags and
distinct_id
to thePHProvider
.
Start by installing uuidv7
and posthog-node
:
npm i uuidv7 posthog-node
Next, create a utils
folder and create a folder named genId.js
. In this file, we use React's cache feature to generate an ID once and return the same value if we call it again.
// app/utils/genId.jsimport { cache } from 'react'import { uuidv7 } from "uuidv7";export const generateId = cache(() => {const id = uuidv7()return id})
After this, we create another file in utils
named getBootstrapData.js
. In it, create a getBootstrapData
function like this:
// app/utils/getBootstrapData.jsimport { PostHog } from 'posthog-node'import { cookies } from 'next/headers'import { generateId } from './genId'export async function getBootstrapData() {let distinct_id = ''const phProjectAPIKey = '<ph_project_api_key>'const phCookieName = `ph_${phProjectAPIKey}_posthog`const cookieStore = cookies()const phCookie = cookieStore.get(phCookieName)if (phCookie) {const phCookieParsed = JSON.parse(phCookie.value);distinct_id = phCookieParsed.distinct_id;}if (!distinct_id) {distinct_id = generateId()}const client = new PostHog(phProjectAPIKey,{ host: "https://us.i.posthog.com" })const flags = await client.getAllFlags(distinct_id)const bootstrap = {distinctID: distinct_id,featureFlags: flags}return bootstrap}
Next:
- Import PostHog (from Node), the Next
cookies
function, and thegenerateId
utility. - Import and use the
getBootstrapData
function and logic. - Call it from the
RootLayout
. - Pass the data to the
PHProvider
.
// app/layout.jsimport './globals.css'import PHProvider from "./providers";import { getBootstrapData } from './utils/getBootstrapData'export default async function RootLayout({ children }) {const bootstrapData = await getBootstrapData()return (<html lang="en"><PHProvider bootstrapData={bootstrapData}><body>{children}</body></PHProvider></html>)}
Finally, in providers.js
, we handle the bootstrapData
and add it to the PostHog initialization.
// app/providers.js'use client'import posthog from 'posthog-js'import { PostHogProvider } from 'posthog-js/react'export default function PHProvider({ children, bootstrapData }) {if (typeof window !== 'undefined') {posthog.init("<ph_project_api_key>", {api_host: "https://us.i.posthog.com",bootstrap: bootstrapData})}return <PostHogProvider client={posthog}>{children}</PostHogProvider>}
Now, feature flag data is available as soon as PostHog loads. Bootstrapping flags like this ensures a user's experience is consistent and you track them accurately.
6. Implementing our A/B test
The final part is to implement the A/B test in our component. There are two ways to do this:
- A client-side implementation where we wait for PostHog to load and use it to control display logic.
- A server-side implementation where we use the bootstrap data directly.
Client-side implementation
To set up our A/B test in app/page.js
:
- Change it to a client-side rendered component.
- Set up PostHog using the
usePostHog
hook. - Use a
useEffect
to check the feature flag. - Change the button text based on the flag value.
// app/page.js'use client'import { usePostHog } from 'posthog-js/react'import { useEffect, useState } from 'react'export default function Home() {const posthog = usePostHog()const [text, setText] = useState('')useEffect(() => {const flag = posthog.getFeatureFlag('main-cta')setText(flag === 'test' ? 'Click this button for free money' : 'Click me');}, [])return (<main><h1>Next.js A/B tests</h1><button id="main-cta">{text}</button></main>)}
When you reload the app, you see our app still needs to wait for PostHog to load even though we are loading flags as fast as possible with bootstrapping. This causes the "flicker," but is solvable if we server-render the component.
Server-side implementation
We can use the same getBootstrapData
function in a server-rendered page and access the data directly. Next.js caches the response, meaning it is consistent with the bootstrapped data.
To set up the A/B test, we change the app/page.js
component to be server-rendered and await the bootstrapData
to use it to set the button text.
// app/page.jsimport { getBootstrapData } from "./utils/getBootstrapData"export default async function Home() {const bootstrapData = await getBootstrapData()const flag = bootstrapData.featureFlags['main-cta']const buttonText = flag === "test" ? "Click this button for free money" : "Click me";return (<main><h1>Next.js A/B tests</h1><button id="main-cta">{buttonText}</button></main>);}
This shows your updated button text immediately on load. This method still uses the client-side for tracking, and this works because we bootstrap the distinct ID from the server-side to the client.
Further reading
- How, when, and where to run your first A/B test
- 10 things we've learned about A/B testing for startups
- How to set up Next.js app router analytics, feature flags, and more