Next.js (App Router)
@avsbhq/next covers the App Router end to end: one component in your root layout resolves the visitor, fetches the datafile, evaluates every flag on the server, and mounts the client provider with the datafile already in hand. The HTML your visitors receive contains their variation, and the browser makes no flag request before first paint.
This page assumes Next.js 15 or later and React 18 or later.
Install
One package. @avsbhq/core, @avsbhq/browser, @avsbhq/react and @avsbhq/utils arrive with it, so app code has one import.
Add your SDK key
Open Environments in your A vs B project sidebar and copy the key for the environment you are wiring up.
Wrap the root layout in AvsbRoot
AvsbRoot is a Server Component from @avsbhq/next/server. It is the whole setup: server evaluation, the bootstrap into the page, and the client provider.
Add the middleware
A Server Component cannot write a cookie, so without the middleware an anonymous visitor is re-randomised on every request.
Read flags in Client Components
The hooks from @avsbhq/react are re-exported here. Reads answer on the first render, with no loading flash.
Record exposures where the variation is shown
Reads never fire exposures, on purpose. useExposure is what tells your results a visitor saw the variation.
Install
npm install @avsbhq/next@avsbhq/node is not one of its dependencies. Install it separately when you also want the server SDK for route handlers, server actions, or background jobs.
Add your SDK key
# .env.localAVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xnAvsbRoot reads AVSB_SDK_KEY, then NEXT_PUBLIC_AVSB_SDK_KEY, or takes an sdkKey prop. You do not need both variables: the key reaches the browser as a prop either way.
Wrap the root layout
// app/layout.tsximport { AvsbRoot } from '@avsbhq/next/server'import type { ReactNode } from 'react'export default function RootLayout({ children }: { children: ReactNode }) { return ( <html lang="en"> <body> <AvsbRoot>{children}</AvsbRoot> </body> </html> )}That is the whole client-side setup. AvsbRoot writes a safely escaped bootstrap into the page (the datafile, the evaluated flags, and the evaluation context) and mounts the provider around your tree.
Its full prop surface:
import type { EvalContext } from '@avsbhq/next'import type { ReactNode } from 'react'interface AvsbRootProps { /** Defaults to AVSB_SDK_KEY, then NEXT_PUBLIC_AVSB_SDK_KEY. */ sdkKey?: string /** Defaults to the anonymous visitor cookie. */ context?: EvalContext /** Default 'https://cdn.avsb.cloud'. */ cdnHost?: string /** Datafile cache window in seconds. Default 60. */ revalidate?: number /** Set false to mount the provider without writing the bootstrap script. */ emitBootstrapScript?: boolean children: ReactNode}Add the middleware
Next.js 16 calls the middleware file proxy.ts and its export proxy:
// proxy.tsimport { withAvsb } from '@avsbhq/next/middleware'export const proxy = withAvsb()export const config = { matcher: ['/((?!_next|.*\\..*).*)'] }On Next.js 15 the file is middleware.ts and the export is middleware: export const middleware = withAvsb(). That form still works on 16, with a deprecation warning in the dev server.
withAvsb() maintains the avsb_anon_id cookie on whatever response goes out, redirects included, so an anonymous visitor keeps the same bucket across visits. Without it their results do not add up, and the SDK says so once in your server logs.
It also wraps middleware you already have. Your return value passes through untouched, and returning nothing continues the request:
// proxy.ts (middleware.ts on Next.js 15)import { NextResponse } from 'next/server'import type { NextRequest } from 'next/server'import { withAvsb } from '@avsbhq/next/middleware'import { server } from './lib/avsbServer'export const proxy = withAvsb( (request: NextRequest) => { if (request.nextUrl.pathname === '/old') { return NextResponse.redirect(new URL('/new', request.url)) } return undefined }, { server, contextFrom: (request: NextRequest) => ({ kind: 'user' as const, key: request.cookies.get('uid')?.value ?? 'anon', }), },)With server and contextFrom, the request runs inside an evaluation scope, so getRequestClient() from @avsbhq/utils works in anything downstream of the middleware.
That scope covers the middleware chain, route handlers, and server actions. It does not reach React Server Component renders, which Next.js runs separately. Evaluate in an RSC with evaluateFlagServer, or let AvsbRoot do it.
Read a flag in a Client Component
// app/CheckoutButton.tsx'use client'import { useBoolFlag, useExposure, useTrack } from '@avsbhq/next'import type { Flag } from '@avsbhq/next'export function CheckoutButton() { const flag: Flag<boolean> = useBoolFlag('new_checkout_flow', false) const track = useTrack() useExposure('new_checkout_flow') return ( <button type="button" onClick={() => track('checkout_clicked')}> {flag.isEnabled() ? 'New checkout' : 'Checkout'} </button> )}Every read returns a Flag<T> object, never a bare value, and every read takes a default. The default is what you get before the SDK is ready, when the key does not exist, and when the flag's declared type does not match the getter you called. The whole hook surface and the Flag<T> shape are documented in the SDK installation guide.
Read a flag in a Server Component
// app/pricing/page.tsximport { getDatafile, evaluateFlagServer } from '@avsbhq/next/server'import type { EvalContext, Flag } from '@avsbhq/next/server'export default async function PricingPage() { const datafile = await getDatafile(process.env.AVSB_SDK_KEY ?? '') const context: EvalContext = { kind: 'user', key: 'u_123', plan: 'pro' } const flag: Flag<string> = evaluateFlagServer(datafile, context, 'pricing_experiment', 'control') return <PricingPanel variation={flag.variationKey ?? 'control'} />}evaluateFlagServer is pure: no network, no state, no side effects. Call it as often as you like inside one render. getDatafile is deduplicated within a request and cached across requests for revalidate seconds (60 by default), so ten Server Components asking for the same key cost one fetch.
Server evaluation fires no exposures
An RSC render is not proof a person saw anything, so server evaluation records nothing. Pair it with useExposure in the Client Component that renders the variation:
// app/PricingPanel.tsx'use client'import { useExposure } from '@avsbhq/next'export function PricingPanel({ variation }: { variation: string }) { useExposure('pricing_experiment') return variation === 'usage-based' ? <UsageBased /> : <Tiered />}Without that pairing, a server-rendered variation is invisible in your results.
Identify a visitor
Pass context to AvsbRoot once you know who the visitor is. The server render and the browser then evaluate the same person:
// app/layout.tsximport { AvsbRoot } from '@avsbhq/next/server'import type { EvalContext } from '@avsbhq/next'import type { ReactNode } from 'react'import { auth } from './lib/auth'export default async function RootLayout({ children }: { children: ReactNode }) { const session = await auth() const context: EvalContext | undefined = session ? { kind: 'user', key: session.userId, plan: session.plan } : undefined return ( <html lang="en"> <body> <AvsbRoot context={context}>{children}</AvsbRoot> </body> </html> )}Omitting context keeps the anonymous cookie identity. In a Client Component, useIdentify() switches the visitor after a sign-in without a reload. Call it from an event handler or an effect, never during render.
Track conversions
// app/PurchaseButton.tsx'use client'import { useTrack } from '@avsbhq/next'export function PurchaseButton() { const track = useTrack() return ( <button type="button" onClick={() => track('purchase', { value: 49.99, properties: { plan: 'pro' } })}> Buy now </button> )}Server-side tracking uses @avsbhq/node, a separate install:
// lib/avsbServer.tsimport { AvsbServer } from '@avsbhq/node'export const server = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY ?? '' })When the datafile fetch fails
A failed fetch does not fail your page. AvsbRoot logs the reason (status, URL, and the fix), renders anyway, and every read returns the default you passed until the client's own retry succeeds. In the browser, useAvsbStatus() tells you where you stand:
// app/FlagStatus.tsx'use client'import { useAvsbStatus } from '@avsbhq/next'export function FlagStatus() { const { status, error, degraded } = useAvsbStatus() if (status === 'error') return <ErrorBanner message={error?.message} /> if (degraded) return <StaleFlagsNotice /> return null}degraded means a cached or bootstrapped datafile is being served because a refresh failed. Flags still answer, and it clears itself when a refresh succeeds.
Testing
@avsbhq/react/testing renders the real provider around a real client built from a flags map, so a component test exercises the same code path production does:
// app/CheckoutButton.test.tsximport { render, screen } from '@testing-library/react'import { AvsbTestProvider } from '@avsbhq/react/testing'import { CheckoutButton } from './CheckoutButton'test('shows the new checkout when the flag is on', () => { render( <AvsbTestProvider flags={{ 'new_checkout_flow': true }}> <CheckoutButton /> </AvsbTestProvider>, ) expect(screen.getByRole('button')).toHaveTextContent('New checkout')})Server code is tested by calling it:
// app/pricing/page.test.tsimport { evaluateFlagServer } from '@avsbhq/next/server'import { createTestDatafile } from '@avsbhq/react/testing'const testDatafile = createTestDatafile({ 'pricing_experiment': 'usage-based' })const evaluated = evaluateFlagServer( testDatafile, { kind: 'user', key: 'u_1' }, 'pricing_experiment', 'control',)expect(evaluated.value).toBe('usage-based')What changed in 1.x
| Change | What to do |
|---|---|
avsbNextAppMiddleware is deleted. It returned an empty 200 for every matched request, which blanked the site. | Use withAvsb(yourMiddleware?, options?). |
AvsbServerProvider is deleted. | Use AvsbRoot, which also mounts the client provider. |
AvsbProvider requires sdkKey. | Pass it. The "no sdkKey needed" pattern never worked: without a key the client cannot refresh the datafile. |
| The bootstrap blob carries the datafile by default and is escaped. | Nothing, unless you relied on the client fetching the datafile again. |
getDatafile requests {cdnHost}/{sdkKey}/datafile.json. | Nothing. The old path did not exist and always failed. |
What's next
- Multi-context identity: target users by organization, device, or your own context kinds.
- Sticky bucketing: keep an assignment stable across sessions.
- SDK installation: the hook surface,
Flag<T>, and every evaluation source. - Next.js (Pages Router) if you are on
pages/.