Skip to content
Dashboard

How to build zero-CLS A/B tests with Next.js and Vercel Edge Config

A recipe for powerful, statically-rendered experiments at scale.

Want to build better experiments?

We're happy to help.

Get in Touch

Link to headingGreat experiments matter

Slow load times have direct impact on user behavior.¹Slow load times have direct impact on user behavior.¹
Slow load times have direct impact on user behavior.¹

Link to headingDefining the experiment engine

Link to headingLeveraging Edge Config for read speed

middleware.ts
import { NextResponse, NextRequest } from 'next/server'
import { get } from '@vercel/edge-config'
export async function middleware(request: NextRequest) {
if (await get("showNewDashboard")) {
return NextResponse.rewrite(new URL("/new-dashboard", request.url))
}
}

Using an Edge Config to rewrite to a new dashboard design.

getExperiments.ts
import { createClient } from '@vercel/edge-config'
import { EdgeConfigDataAdapter } from 'statsig-node-vercel'
import Statsig from 'statsig-node'
async function initializeStatsig() {
const edgeConfigClient = createClient(process.env.EDGE_CONFIG)
const dataAdapter = new EdgeConfigDataAdapter({
edgeConfigClient: edgeConfigClient,
edgeConfigItemKey: process.env.STATSIG_EDGE_CONFIG_ITEM_KEY,
})
await Statsig.initialize(process.env.STATSIG_SERVER_KEY, { dataAdapter })
}
async function getExperiment(userId, experimentName) {
await initializeStatsig()
return Statsig.getExperiment({ userId }, experimentName)
}

Populating our runtime with our experimentation configuration.

Link to headingUsing Next.js for high-performance, flexible experiences

Link to headingCreating experiments in code

experiments.ts
export const EXPERIMENTS = {
pricing_redesign: {
params: {
enabled: [false, true],
bgGradientFactor: [1, 42]
},
paths: ['/pricing']
},
skip_button: {
params: {
skip: [false, true]
},
// A client-side experiment won't need path values
paths: []
}
} as const

Creating static types for our source code.

Link to headingPre-rendering experiment variations

Dynamic routes using encoded experiment values for their parameter slugs.Dynamic routes using encoded experiment values for their parameter slugs.
Dynamic routes using encoded experiment values for their parameter slugs.
experiments/engine/data-fetchers.ts
// Your encoding implementation
import { encodeVariations, decodeVariations } from './encoders'
export function experimentGetStaticPaths(
path,
maxGeneratedPaths = 100
) {
return (context) => {
const paths = encodeVariations(path, maxGeneratedPaths)
return {
paths,
fallback: 'blocking',
}
}
}
export function experimentGetStaticProps(pageGetStaticProps) {
return async (context) => {
const { props: pageProps, revalidate } = await pageGetStaticProps(context)
const encodedRoute = context.params?.experiments
// Read from URL or use default values
const experiments = decodeVariations(encodedRoute) ?? EXPERIMENT_DEFAULTS
return {
props: {
...pageProps,
experiments
},
revalidate
}
}

Creating data fetching utilities.

pages/pricing/[experiments].ts
export const getStaticPaths = experimentGetStaticPaths("/pricing")
export const getStaticProps = experimentGetStaticProps(async () => {
const { prices } = await fetchPricingMetadata()
return {
props: {
prices
}
}
})

Using our engine's data fetching utilities.

Link to headingServing experiments to users

middleware.ts
import { NextResponse } from 'next/server'
async function getExperimentsForRequest(req) {
const cookie = getExperimentsCookie(req)
const experiments = cookie
? parseExperiments(cookie)
: readExperimentsFromEdgeConfigAndUpdateCookie(req)
return experiments
}
export async function middleware(req) {
const experiments = await getExperimentsForRequest(req)
const path = getPathForExperiment(experiments, req)
return NextResponse.rewrite(new URL(path, req.url))
}
export const config = {
matcher: '/pricing'
}

It's possible for a client's cookie to go stale and end up out of sync with our experiment configuration. We'll get into how we keep the cookie fresh in a moment.

Pricing.tsx
function Pricing({ prices }) {
const { enabled, bgGradientFactor } = useExperiment("pricing_redesign")
return (
<div>
<h1>Pricing</h1>
{
enabled ?
<GradientPricingTable factor={bgGradientFactor} prices={prices} />
: <PricingTable prices={prices} />
}
</div>
)
}

Using a hook for a statically rendered experiment.

Link to headingMeasuring success

analyticsContext.ts
export function trackExperiment(experimentName) {
analytics(EXPERIMENT_VIEWED, getTrackingMetadataForExperiment(experimentName))
}
const Context = createContext()
export function ExperimentContext({
experiments,
path,
children
}) {
useEffect(() => {
for (const experimentName of getExperimentsForPath(path)) {
trackExperiment(experimentName);
}
}, [])
return (
<Context.Provider experiments={experiments}>
{children}
</Context.Provider>
)
}

Building a React Context to easily track analytics.

Link to headingHandling client-side experiments

components/PricingModal.ts
function PricingModal() {
// Fully-typed `skip` from EXPERIMENTS constant under the hood
const { skip } = useClientSideExperiment("skip_button")
return (
<Modal>
<Modal.Title>Invite Teammates</Modal.Title>
<Modal.Description>Add members to your team.</Modal.Description>
<Modal.Button>Ok</Modal.Button>
{skip ? <Modal.Button>Skip</Modal.Button> : null}
</Modal>
)
}

Link to headingEnsuring experiments stay fresh

Link to headingEffective experiments, every time

Build better experiments

Get started today.

Let's Talk