SvelteKit
This guide wires A vs B into a SvelteKit app end to end: flags evaluated on the server for the current visitor, values available in every load function, and a browser client that starts with the same datafile so nothing flickers.
For a Svelte app without SvelteKit, see Vite + Svelte.
Install
Install @avsbhq/svelte for the browser and @avsbhq/node for the server.
Wrap your handle
withAvsbHooks binds your server client to each request's visitor and puts it on event.locals.avsb.
Declare the locals type
One optional field in src/app.d.ts tells TypeScript what locals.avsb is.
Return the bootstrap from a load function
locals.avsb.bootstrap is the datafile. Return it from +layout.server.ts so the page carries it.
Mount the provider
Pass that datafile to AvsbProvider and read flags anywhere with stores or runes.
Install the packages:
npm install @avsbhq/svelte @avsbhq/node1. The SDK key
Open Environments in your A vs B project sidebar and copy the SDK key for the environment you are targeting. It looks like sdk_production_ttqm0eaj4vth1krcb2xn.
# .envPUBLIC_AVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xnOne key covers both sides. The server helper and the browser provider use the same value, which is why the example reads it from $env/static/public.
2. Server hooks
// src/hooks.server.tsimport { AvsbServer } from '@avsbhq/node'import { withAvsbHooks } from '@avsbhq/svelte/sveltekit'import { PUBLIC_AVSB_SDK_KEY } from '$env/static/public'import type { Handle } from '@sveltejs/kit'const avsb = new AvsbServer({ sdkKey: PUBLIC_AVSB_SDK_KEY })export const handle: Handle = withAvsbHooks( ({ event, resolve }) => resolve(event), { serverClient: avsb, resolveContext: (event) => ({ kind: 'user', key: event.cookies.get('uid') ?? 'anonymous', }), })withAvsbHooks takes SvelteKit's own Handle and returns one, so it drops into hooks.server.ts with no cast and composes with sequence() in either order:
import { sequence } from '@sveltejs/kit/hooks'import { authHandle, contextFor } from './hooks/auth'export const handle: Handle = sequence( authHandle, withAvsbHooks(({ event, resolve }) => resolve(event), { serverClient: avsb, resolveContext: (event) => contextFor(event.locals.user), }))Create the AvsbServer once, at module scope. It holds the datafile in memory and refreshes it in the background, so a new one per request would fetch the same document over and over.
Skipping requests
resolveContext returning null skips A vs B for that request and sets no locals at all. Use it for static assets, health checks, or any route with no visitor:
import type { WithAvsbHooksOptions } from '@avsbhq/svelte/sveltekit'const resolveContext: WithAvsbHooksOptions['resolveContext'] = (event) => event.url.pathname.startsWith('/healthz') ? null : { kind: 'user', key: event.cookies.get('uid') ?? 'anonymous' }3. Declare locals.avsb
// src/app.d.tsdeclare global { namespace App { interface Locals { // Your own auth hook's locals. Shown here because the sequence() // example above reads `event.locals.user`. user?: { id: string; plan?: string } // Optional: withAvsbHooks sets no locals on a request whose // resolveContext returned null. Make it required only if yours never does. avsb?: import('@avsbhq/svelte/sveltekit').AvsbLocals } }}export {}The optional ? is deliberate. A load function that runs on a skipped request would otherwise read undefined through a type that promised it could not be, which is a crash in production and green in the editor.
4. Reading flags in load
// src/routes/+layout.server.tsimport type { RequestEvent } from '@avsbhq/svelte/sveltekit'import type { LayoutServerLoad } from './$types'export const load: LayoutServerLoad = ({ locals, cookies }: RequestEvent) => ({ // The datafile: this is what <AvsbProvider bootstrap={...}> takes. avsbBootstrap: locals.avsb?.bootstrap ?? null, // The same visitor resolveContext used, so the provider buckets identically. userId: cookies.get('uid') ?? 'anonymous', // Server-evaluated values, if you want the markup decided on the server. newCheckout: locals.avsb?.client.getBoolFlag('new_checkout_flow', false).value ?? false,})Any route or endpoint can do the same:
// src/routes/pricing/+page.server.tsimport type { PageServerLoad } from './$types'export const load: PageServerLoad = ({ locals }: RequestEvent) => { const plans = locals.avsb?.client.getJsonFlag<string[]>('pricing-plans', [ 'starter', 'pro', ]).value return { plans: plans ?? ['starter', 'pro'] }}// src/routes/api/quote/+server.tsimport { json } from '@sveltejs/kit'import type { RequestHandler } from './$types'export const GET: RequestHandler = ({ locals }: RequestEvent) => { const discount = locals.avsb?.client.getNumberFlag('quote_discount', 0).value ?? 0 return json({ discount })}locals.avsb.client is already bound to this request's visitor, so no read takes a context argument and no route can accidentally evaluate for the wrong person.
Exposures from server-rendered markup
Every server evaluation records an exposure. getBoolFlag and its siblings fire one by default, so a flag read in a load function counts as "this visitor saw this decision" the moment the read happens.
That is usually right for a +page.server.ts whose result decides the markup. It is wrong in two common cases: a layout that reads a flag on every route whether or not the variation is shown, and a read that happens before you know the visitor will reach the page.
For those, suppress the exposure on the read and record it where the visitor actually sees the variation:
// src/routes/+layout.server.ts (the same load as above, exposure deferred)import { DecideOption } from '@avsbhq/node'import type { LayoutServerLoad } from './$types'export const load: LayoutServerLoad = ({ locals }: RequestEvent) => ({ // Read now, count later. newCheckout: locals.avsb?.client.getBoolFlag('new_checkout_flow', false, { decideOptions: [DecideOption.DISABLE_EXPOSURE], }).value ?? false,})Then record it once, at the moment of truth. On the server, when the markup you are about to return is that moment:
// src/routes/checkout/+page.server.tsexport const load: PageServerLoad = ({ locals }: RequestEvent) => { locals.avsb?.client.manualExposure('new_checkout_flow') return {}}Or in the browser, in the component that renders the variation:
<script lang="ts"> import { getExposure } from '@avsbhq/svelte' import { onMount } from 'svelte' const expose = getExposure() onMount(() => expose('new_checkout_flow'))</script>Pick one of the two per flag. Recording on the server and again on the client double-counts the same visitor.
getAllFlags() is the exception: it does not fire exposures unless you ask for them with { fireExposures: true }, because reading the whole set is a diagnostic or a bootstrap, not a decision anyone saw.
5. Mount the provider
<!-- src/routes/+layout.svelte --><script lang="ts"> import AvsbProvider from '@avsbhq/svelte/AvsbProvider.svelte' import { PUBLIC_AVSB_SDK_KEY } from '$env/static/public' let { data, children } = $props()</script><AvsbProvider sdkKey={PUBLIC_AVSB_SDK_KEY} context={{ kind: 'user', key: data.userId }} bootstrap={data.avsbBootstrap}> {@render children()}</AvsbProvider><!-- src/lib/CheckoutButton.svelte --><script lang="ts"> import { boolFlag, getTrack, getExposure } from '@avsbhq/svelte' import { onMount } from 'svelte' const checkout = boolFlag('new_checkout_flow', false) const track = getTrack() const expose = getExposure() onMount(() => expose('new_checkout_flow'))</script><button onclick={() => track('checkout_started', { value: 99 })}> {$checkout.value ? 'Start checkout (new)' : 'Buy now'}</button>With a bootstrap the browser client is ready on its first render: $flagReady is already true, no loading state appears, and the values match what the server rendered. Without one, the first paint shows the default you passed while the datafile is fetched.
Runes work the same way through @avsbhq/svelte/runes:
<script lang="ts"> import { boolFlagState } from '@avsbhq/svelte/runes' const checkout = boolFlagState('new_checkout_flow', false)</script>{#if checkout.current.isEnabled()} <NewCheckout />{/if}Teardown
<AvsbProvider> closes its own client for you. When it built the client itself (you passed sdkKey, not client), it calls Svelte's onDestroy the moment the component unmounts, no matter how deep in the tree it sits:
<!-- AvsbProvider.svelte, the part that matters here -->const { cleanup } = createAvsbContext(options)onDestroy(cleanup)Pass your own client instead (Mode B) and the provider never closes it, because it never owned it. Close that client yourself, wherever you built it.
6. The server helper API
// Everything @avsbhq/svelte/sveltekit exports.import type { Handle, RequestEvent } from '@avsbhq/svelte/sveltekit'import type { DecideOption, DecisionLog, EvalContext, Flag, FlagDatafile,} from '@avsbhq/core'function withAvsbHooks(handle: Handle, options: WithAvsbHooksOptions): Handlefunction getAvsbBootstrap(serverClient: AvsbServerClient): FlagDatafile | nullfunction bindAvsbClient( serverClient: AvsbServerClient, context: EvalContext): AvsbBoundClientinterface WithAvsbHooksOptions { /** Your AvsbServer instance from @avsbhq/node. */ serverClient: AvsbServerClient /** Return null to skip A vs B for this request; no locals are set. */ resolveContext: (event: RequestEvent) => EvalContext | null}interface AvsbLocals { /** The context this request was evaluated for. */ evalContext: EvalContext /** Flag reads bound to that context. */ client: AvsbBoundClient /** The datafile, or null when the server client has not loaded one yet. */ bootstrap: FlagDatafile | null}interface AvsbServerClient { forUser(context: EvalContext): AvsbBoundClient getDatafile(): FlagDatafile | null}interface AvsbBoundClient { getFlag<T>(flagKey: string, defaultValue: T, options?: AvsbBoundReadOptions): Flag<T> getBoolFlag(flagKey: string, defaultValue: boolean, options?: AvsbBoundReadOptions): Flag<boolean> getStringFlag(flagKey: string, defaultValue: string, options?: AvsbBoundReadOptions): Flag<string> getNumberFlag(flagKey: string, defaultValue: number, options?: AvsbBoundReadOptions): Flag<number> getJsonFlag<T>(flagKey: string, defaultValue: T, options?: AvsbBoundReadOptions): Flag<T> getAllFlags(options?: AvsbBoundAllFlagsOptions): Record<string, Flag> /** Record that the visitor was shown this decision. */ manualExposure(flagKey: string): void getContext(): EvalContext}interface AvsbBoundReadOptions { /** Per-call decide options, e.g. [DecideOption.DISABLE_EXPOSURE]. */ decideOptions?: DecideOption[] /** Append this evaluation to a decision log. */ decisionLog?: DecisionLog}interface AvsbBoundAllFlagsOptions extends AvsbBoundReadOptions { /** Record an exposure for every flag evaluated. Default false. */ fireExposures?: boolean}// `Handle` and `RequestEvent` are SvelteKit's own types, re-exported here.AvsbServer from @avsbhq/node satisfies AvsbServerClient, and the client its forUser(context) returns satisfies AvsbBoundClient, so nothing needs casting.
bootstrap is the datafile, not a map of evaluated values. It is JSON-safe by construction because it is the same document the CDN serves, which is what lets it travel through load data into the provider untouched.
7. Deploying behind a CDN
load data, including the bootstrap datafile, is serialised into the page. If a route is cached publicly, cache the page for everyone or not at all: a page carrying one visitor's evaluated values must not be served to another visitor. Two safe patterns:
- Return only
avsbBootstrapfrom a cached route and let the browser client evaluate. Every visitor gets the same datafile and buckets on their own id. - Keep server-evaluated values on routes you render per request.
8. Troubleshooting
| What you see | Why |
|---|---|
locals.avsb is undefined in a load | resolveContext returned null for that request, or withAvsbHooks is not in the handle chain. |
| Flags are all defaults on the server | The server client had not loaded a datafile yet. Await avsb.onReady() during startup, or pass a bootstrap to the AvsbServer constructor. |
| Values flip after hydration | The browser evaluated a different visitor. Make sure the provider's context matches what resolveContext returned. |
bootstrap is null in the page | Same as above: the server client has no datafile yet. The browser fetches its own, so the page still works. |