Next.js

Server side A/B testing through Next.js middleware

This example runs a server side A/B test with the Next.js SDK. The middleware decides the variant before the page is rendered and rewrites the request to an internal route, so the first paint already matches the visitor's variant — no layout shift. The React SDK is still used on the client to post analytics.

Install

npm i @obelism/improve-sdk @obelism/improve-sdk-next @obelism/improve-sdk-react

Shared config

A small shared config keeps the organization and environment in one place for both the middleware and the client provider.

src/app/improveConfig.ts
import { ImproveSetupArgs } from '@obelism/improve-sdk/types'

export const IMPROVE_CONFIG: ImproveSetupArgs = {
	organizationId: 'org_MJFL46Z0WXGQ5OHW1ZXSM3Q88S',
	environment: 'staging',
} as const

Middleware

The middleware creates an ImproveServerSDK, fetches the config, and hands the request to generateImproveNextMiddleware. Each server test maps a route to the internal slugs visitors are rewritten to.

src/middleware.ts
import { ImproveServerSDK } from '@obelism/improve-sdk/server'
import { generateImproveNextMiddleware } from '@obelism/improve-sdk-next'
import { type NextRequest } from 'next/server'

import { IMPROVE_CONFIG } from './app/improveConfig'

export const config = {
	matcher: '/',
}

const improveSdk = new ImproveServerSDK({
	...IMPROVE_CONFIG,
	token: 'xxxx-xxxx-xxxx-xxxx',
})

const improveMiddlewareHandler = generateImproveNextMiddleware({
	improveSdk,
	serverABtests: [
		{
			slug: 'startpage-visual',
			routeHandler: '/',
			options: [
				{
					value: 'control',
					slug: '/',
				},
				{
					value: 'variation',
					slug: '/variation',
				},
			],
		},
	],
})

export const middleware = async (request: NextRequest) => {
	await improveSdk.fetchConfig()
	return improveMiddlewareHandler(request)
}

Note: the runnable example hardcodes the server token for brevity. In your own app load it from an environment variable so it never ends up in version control.

Client provider

The provider has to be a client component because it loads the SDK and fetches the config in a useEffect. Re-export it from a 'use client' file.

src/app/improve.ts
'use client'

import { generateImproveProvider } from '@obelism/improve-sdk-react'
import { IMPROVE_CONFIG } from './improveConfig'

const improveReact = generateImproveProvider(IMPROVE_CONFIG)

export const ImproveProvider = improveReact.ImproveProvider
export const useTestValue = improveReact.useTestValue
export const useFlagValue = improveReact.useFlagValue
export const usePostAnalytic = improveReact.usePostAnalytic

Wrap the app with the provider in the root layout:

src/app/layout.tsx
import { ImproveProvider } from './improve'

export default function RootLayout({
	children,
}: Readonly<{ children: React.ReactNode }>) {
	return (
		<html lang="en">
			<body>
				<ImproveProvider>{children}</ImproveProvider>
			</body>
		</html>
	)
}

Routes

Each variant is a normal route. The middleware rewrites / to /variation for visitors in that variant, so both pages render the same HomePage with a different variant prop.

src/app/page.tsx
import { HomePage } from './HomePage'

export default function Home() {
	return <HomePage variant="control" />
}
src/app/variation/page.tsx
import { HomePage } from '../HomePage'

export default function Home() {
	return <HomePage variant="variation" />
}

Tracking

Client components post analytics with usePostAnalytic. It takes no arguments and events are test-independent — call it with an event name (and optional message) and the server attributes the event to whichever tests/flags the visitor was exposed to. Because this example resolves the variant server-side (via middleware rewrite), read the variant client-side with useTestValue so the SDK records the exposure that events are attributed to. TrackPageView reports a page load, Visual reports a click on the variant.

src/app/TrackPageView.tsx
'use client'

import { useEffect } from 'react'
import { usePostAnalytic, useTestValue } from './improve'

export const TrackPageView = ({ page }: { page: string }) => {
	const postAnalytic = usePostAnalytic()
	// Reading the variant records the exposure events are attributed to.
	useTestValue('startpage-visual')

	useEffect(() => {
		postAnalytic('page_view', page)
	}, [page, postAnalytic])

	return null
}
src/app/Visual.tsx
'use client'

import Image from 'next/image'
import { usePostAnalytic } from './improve'

export const Visual = ({ variant }: { variant: 'control' | 'variation' }) => {
	const postAnalytic = usePostAnalytic()

	return (
		<Image
			src="/next.svg"
			alt="Next.js Logo"
			width={180}
			height={37}
			priority
			style={variant === 'control' ? {} : { transform: 'scaleX(-1)' }}
			onClick={() => {
				postAnalytic('visual_click')
			}}
		/>
	)
}

Source

On this page