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

Copy 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.¹

Copy link to headingDefining the experiment engine

Copy 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.

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

Copy 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.

Copy 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.

Copy 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'
}

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.

Copy 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.

Copy 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>
)
}

Copy link to headingEnsuring experiments stay fresh

Copy link to headingEffective experiments, every time

Build better experiments

Get started today.

Let's Talk

Ready to deploy?