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.

1

Install

Install @avsbhq/svelte for the browser and @avsbhq/node for the server.

2

Wrap your handle

withAvsbHooks binds your server client to each request's visitor and puts it on event.locals.avsb.

3

Declare the locals type

One optional field in src/app.d.ts tells TypeScript what locals.avsb is.

4

Return the bootstrap from a load function

locals.avsb.bootstrap is the datafile. Return it from +layout.server.ts so the page carries it.

5

Mount the provider

Pass that datafile to AvsbProvider and read flags anywhere with stores or runes.

Install the packages:

Shell
npm install @avsbhq/svelte @avsbhq/node
Shell1 line

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

Shell
# .envPUBLIC_AVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xn
Shell2 lines
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.

One 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

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

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:

TypeScript
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),  }))
TypeScript10 lines

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:

TypeScript
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' }
TypeScript6 lines

3. Declare locals.avsb

TypeScript
// 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 {}
TypeScript15 lines

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

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

Any route or endpoint can do the same:

TypeScript
// 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'] }}
TypeScript11 lines
TypeScript
// 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 })}
TypeScript8 lines

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:

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

Then record it once, at the moment of truth. On the server, when the markup you are about to return is that moment:

TypeScript
// src/routes/checkout/+page.server.tsexport const load: PageServerLoad = ({ locals }: RequestEvent) => {  locals.avsb?.client.manualExposure('new_checkout_flow')  return {}}
TypeScript5 lines

Or in the browser, in the component that renders the variation:

Svelte
<script lang="ts">  import { getExposure } from '@avsbhq/svelte'  import { onMount } from 'svelte'  const expose = getExposure()  onMount(() => expose('new_checkout_flow'))</script>
Svelte7 lines

Pick one of the two per flag. Recording on the server and again on the client double-counts the same visitor.

Info

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

Svelte
<!-- 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>
Svelte15 lines
Svelte
<!-- 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>
Svelte15 lines

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:

Svelte
<script lang="ts">  import { boolFlagState } from '@avsbhq/svelte/runes'  const checkout = boolFlagState('new_checkout_flow', false)</script>{#if checkout.current.isEnabled()}  <NewCheckout />{/if}
Svelte9 lines

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:

Svelte
<!-- AvsbProvider.svelte, the part that matters here -->const { cleanup } = createAvsbContext(options)onDestroy(cleanup)
Svelte3 lines

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

TypeScript
// 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.
TypeScript63 lines

AvsbServer from @avsbhq/node satisfies AvsbServerClient, and the client its forUser(context) returns satisfies AvsbBoundClient, so nothing needs casting.

Info

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 avsbBootstrap from 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 seeWhy
locals.avsb is undefined in a loadresolveContext returned null for that request, or withAvsbHooks is not in the handle chain.
Flags are all defaults on the serverThe server client had not loaded a datafile yet. Await avsb.onReady() during startup, or pass a bootstrap to the AvsbServer constructor.
Values flip after hydrationThe browser evaluated a different visitor. Make sure the provider's context matches what resolveContext returned.
bootstrap is null in the pageSame as above: the server client has no datafile yet. The browser fetches its own, so the page still works.
Was this helpful?