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.

1

Install

One package. @avsbhq/core, @avsbhq/browser, @avsbhq/react and @avsbhq/utils arrive with it, so app code has one import.

2

Add your SDK key

Open Environments in your A vs B project sidebar and copy the key for the environment you are wiring up.

3

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.

4

Add the middleware

A Server Component cannot write a cookie, so without the middleware an anonymous visitor is re-randomised on every request.

5

Read flags in Client Components

The hooks from @avsbhq/react are re-exported here. Reads answer on the first render, with no loading flash.

6

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

Shell
npm install @avsbhq/next
Shell1 line

@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

Shell
# .env.localAVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xn
Shell2 lines

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

Your SDK key is public
Your SDK key is a public identifier, not a secret: it is safe to ship in browser and mobile bundles, it can only fetch that environment's flag configuration and send events, and it can never read or change anything in your dashboard. Credentials covers all four A vs B credentials and which one to reach for.

Wrap the root layout

TypeScript React
// 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>  )}
TypeScript React13 lines

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:

TypeScript
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}
TypeScript16 lines

Add the middleware

Next.js 16 calls the middleware file proxy.ts and its export proxy:

TypeScript
// proxy.tsimport { withAvsb } from '@avsbhq/next/middleware'export const proxy = withAvsb()export const config = { matcher: ['/((?!_next|.*\\..*).*)'] }
TypeScript6 lines

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:

TypeScript
// 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',    }),  },)
TypeScript21 lines

With server and contextFrom, the request runs inside an evaluation scope, so getRequestClient() from @avsbhq/utils works in anything downstream of the middleware.

Warning

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

TypeScript React
// 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>  )}
TypeScript React17 lines

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

TypeScript React
// 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'} />}
TypeScript React11 lines

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:

TypeScript React
// 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 />}
TypeScript React8 lines

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:

TypeScript React
// 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>  )}
TypeScript React20 lines

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

TypeScript React
// 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>  )}
TypeScript React13 lines

Server-side tracking uses @avsbhq/node, a separate install:

TypeScript
// lib/avsbServer.tsimport { AvsbServer } from '@avsbhq/node'export const server = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY ?? '' })
TypeScript4 lines

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:

TypeScript React
// 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}
TypeScript React11 lines

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:

TypeScript React
// 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')})
TypeScript React14 lines

Server code is tested by calling it:

TypeScript
// 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')
TypeScript13 lines

What changed in 1.x

ChangeWhat 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

Was this helpful?