Vercel Functions
Vercel offers two function runtimes, and A vs B has a package for each. Use @avsbhq/node in Serverless Functions, where a warm process can hold the datafile, stream updates, and keep sticky assignments. Use @avsbhq/edge in Edge Functions, where the isolate is short-lived and the datafile is cached in Vercel Edge Config.
The two SDKs deliberately do not have the same shape. The server SDK is stateless and takes a context per request. The edge client is bound to one request by its adapter, so it has no forUser and no identify at all.
Node.js runtime
Install
Add the Node SDK to your project.
Obtain your SDK key
Go to Environments in your A vs B project's sidebar and copy the SDK key for the environment this deployment serves. Add it to Vercel as an environment variable named AVSB_SDK_KEY. It does not need a client-side prefix: your functions do the evaluating.
Bootstrap the server SDK
Construct AvsbServer once at module scope so warm invocations reuse the datafile it already holds, and await onReady() before you read anything.
Bind the request context
server.forUser(context) returns a UserBoundClient with its own decision log. Reads on it need no further context.
Read a flag
Call a typed getter for the value plus the metadata that says where it came from.
Track an event
revenue carries money, value carries a quantity. They are two separate columns end to end.
Install the package:
npm install @avsbhq/nodeAdd the key to your Vercel project's environment variables:
AVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xnBootstrap the server SDK at module scope:
import { AvsbServer } from '@avsbhq/node'import type { VercelRequest, VercelResponse } from '@vercel/node'const server = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY ?? '' })export default async function handler(req: VercelRequest, res: VercelResponse): Promise<void> { const init = await server.onReady() if (!init.success) { // Flags serve the defaults you pass until a datafile arrives, and polling // continues in the background. Survivable, not fatal. console.warn('avsb init failed', init.source) } const userId = (req.headers['x-user-id'] as string | undefined) ?? 'anon' const avsb = server.forUser({ kind: 'user', key: userId }) res.json({ showNewCheckout: avsb.getBoolFlag('checkout_v2', false).isEnabled() })}onReady() never rejects and returns the same promise on repeated calls, so awaiting it at the top of every invocation costs nothing after the first.
Reading flags
import type { UserBoundClient, Flag } from '@avsbhq/node'interface CheckoutDecision { enabled: boolean variationKey: string | null reasons: string[]}function decideCheckout(avsb: UserBoundClient): CheckoutDecision { const flag: Flag<boolean> = avsb.getBoolFlag('checkout_v2', false) return { enabled: flag.isEnabled(), variationKey: flag.variationKey, reasons: flag.reasons }}flag.source is one of datafileOverride, runtimeOverride, sticky, rule, holdout, bandit, default, disabled, not_found, or not_ready. isEnabled() is true only when a real decision produced a truthy value, so it is always false for default, disabled, not_found, and not_ready. exists() answers "is this key in the datafile", and is false for both not_found and not_ready.
A typed getter checks the value against the type the dashboard declares for the flag. On a mismatch it logs a warning naming both types and returns your default; it never throws and never coerces.
Tracking events
import type { UserBoundClient } from '@avsbhq/node'function recordConversions(avsb: UserBoundClient): void { avsb.track('purchase_completed', { revenue: 199.0 }) // money, decimal major units avsb.track('items_added', { value: 3 }) // a quantity avsb.track('signup_completed') // a count}revenue and value are two separate columns, so one conversion can carry money, a quantity, or both. properties is accepted by the shared type but is not stored for conversions: the ingestion contract for a metric event keeps the event name, the two numbers, the timestamp, and the visitor id. The SDK warns once and drops them rather than pretending. Exposure events do carry properties.
track is synchronous. Events tracked before the datafile arrives are queued and sent once it does.
Multi-context identity
import type { AvsbServer, UserBoundClient } from '@avsbhq/node'function bindAccount(server: AvsbServer, userId: string, orgId: string): UserBoundClient { return server.forUser({ kind: 'multi', user: { kind: 'user', key: userId, plan: 'pro' }, organization: { kind: 'organization', key: orgId, tier: 'enterprise' }, })}A rule can target organization.tier while bucketing on user.key.
Edge runtime
Vercel Edge Functions run in a V8 isolate: no Node built-ins, no persistent background timers. @avsbhq/edge is built for exactly that, and its Vercel adapter caches the datafile in Vercel Edge Config (Vercel's docs now call it Global Config) so evaluation needs no outbound fetch on a warm isolate.
Install
Add the edge client and the Edge Config helper.
Configure Edge Config
Create an Edge Config store and add its connection string to the project as EDGE_CONFIG.
Bootstrap with createVercelHandler
The factory builds one client per request, binds the visitor context, awaits init, and flushes correctly for the runtime.
Read a flag
The client inside your handler is already bound, so reads take no context argument.
Let the adapter flush
Pass the FetchEvent through and the flush runs on waitUntil; without one it is awaited before the response returns.
Install the packages:
npm install @avsbhq/edge @vercel/edge-configBootstrap the edge client:
import { createVercelHandler } from '@avsbhq/edge/vercel'import { createClient } from '@vercel/edge-config'export const runtime = 'edge'const avsb = createVercelHandler({ sdkKey: process.env.AVSB_SDK_KEY ?? '', edgeConfig: createClient(process.env.EDGE_CONFIG), handler: async (req, client) => { const banner = client.getBoolFlag('new_banner', false) return Response.json({ enabled: banner.isEnabled() }) },})export function GET(req: Request): Promise<Response> { return avsb(req)}There is no onReady() to await and no forUser() to call. init() is awaited by the adapter before your handler runs, and the context the adapter built from the request is already bound to the client.
The adapter reads the key avsb_df_<sdkKey> from Edge Config, and after a network fetch it can only warm its own in-process map, because Edge Config writes go through the Vercel API out of band. If you want a cache shared across isolates, populate that key from CI or a cron. Without it, each cold isolate fetches the datafile once from the A vs B CDN and reuses it for the life of the isolate.
cacheTtlMs (default 5 minutes) decides how long a cached datafile counts as fresh; past it the client revalidates and, if that fails, serves the stale copy with degraded: true. memoryRetentionMs (default 1 hour) decides how long the in-process copy is kept at all.
Reading flags at the edge
import type { AvsbEdgeClient, EvaluationSource, Flag } from '@avsbhq/edge'function decideBanner(client: AvsbEdgeClient): { copy: string; source: EvaluationSource } { const copy: Flag<string> = client.getStringFlag('banner_copy', 'Welcome back') return { copy: copy.value, source: copy.source }}Reading a flag before init() finishes returns your default with source: 'not_ready' and warns once per key, so a pre-ready read is never mistaken for a missing flag.
Tracking and flushing
import type { AvsbEdgeClient } from '@avsbhq/edge'function recordBannerClick(client: AvsbEdgeClient): void { client.track('banner_clicked', { value: 1 })}The adapter calls client.flushEvents() for you: on waitUntil when a FetchEvent is passed as the handler's second argument, and awaited before the response otherwise. Here, payload.value and payload.revenue land in their own columns, the same as the server. payload.properties is dropped here and kept only on exposure events.
Identity at the edge
The Vercel adapter resolves the visitor from the x-avsb-visitor-id header, then the A vs B snippet's _avsb_visitor cookie, and reads Vercel's x-vercel-ip-* geo headers for country, region, city, timezone, latitude, and longitude (the city arrives percent-encoded and is decoded for you). The client IP is never used as the bucketing key.
Pass contextFrom to replace that, building on the exported vercelContextFrom so you keep the platform data. There is no identify() at the edge: the context is fixed for the life of one request.
SSR hydration in Next.js on Vercel
If you are running a Next.js application, use the Next.js App Router integration instead of this page. It handles server-side evaluation and client hydration through the dedicated @avsbhq/next package.
Testing
createMockServer mirrors the server SDK, forUser included, and its bound client has the same read surface as the client an edge handler receives. One fixture drives both:
import { createMockServer, flagsFromTestData, TestData } from '@avsbhq/test'import { describe, it, expect } from 'vitest'const server = createMockServer( flagsFromTestData([ TestData.flag('checkout_v2') .booleanFlag() .variationForUser('u_power', true) .fallthroughVariation(false) .build(), ]),)describe('checkout decision', () => { it('serves v2 to the power user', () => { const avsb = server.forUser({ kind: 'user', key: 'u_power' }) expect(avsb.getBoolFlag('checkout_v2', false).value).toBe(true) }) it('serves the fallthrough to everyone else', () => { const avsb = server.forUser({ kind: 'user', key: 'u_other' }) expect(avsb.getBoolFlag('checkout_v2', false).value).toBe(false) }) it('attributes a tracked event to the bound context', () => { server.forUser({ kind: 'user', key: 'u_power' }).track('purchase', { revenue: 99 }) expect(server._tracked()).toEqual([ { eventKey: 'purchase', payload: { revenue: 99 }, contextKey: 'u_power' }, ]) })})What's next
- Next.js App Router integration: the full SSR and RSC pattern for Next.js deployed on Vercel.
- Cloudflare Workers integration: the same
@avsbhq/edgepattern with Cloudflare KV. - Edge SDK reference: the full
AvsbEdgeClientAPI surface. - SDK installation:
Flag<T>, every evaluation source, and the options every A vs B SDK shares.