Vite + Svelte
This guide covers a Vite project running Svelte 5 as a single-page app: one provider at the root, reactive flag reads with either the stores ($checkout) or the runes accessors (checkout.current), exposures recorded where the visitor actually sees a variation, and tests that run the real evaluator.
Building a SvelteKit app instead? Server hooks, locals.avsb, and the bootstrap datafile that removes the first-paint flicker are covered in SvelteKit. Everything below still applies to the browser half of a SvelteKit app.
Install
One package. @avsbhq/svelte brings @avsbhq/browser with it.
Copy your SDK key
Open Environments in the project sidebar and expose the key as a VITE_ variable.
Mount one provider
AvsbProvider.svelte at the root of App.svelte.
Read a flag
boolFlag, stringFlag, numberFlag, jsonFlag, or flag for any type. Runes accessors live on @avsbhq/svelte/runes.
Record the exposure
getExposure() in the component that shows the variation. Reads record nothing.
Track a conversion
getTrack() sends the events your metrics count.
npm install @avsbhq/svelte@avsbhq/browser is a dependency of @avsbhq/svelte, so it arrives with it. @sveltejs/kit is an optional peer, needed only for the server helpers.
1. The SDK key
Open Environments in your A vs B project sidebar and copy the key for the environment you are targeting. The format is sdk_<environment>_<id>, for example sdk_production_ttqm0eaj4vth1krcb2xn.
# .envVITE_AVSB_SDK_KEY=sdk_production_ttqm0eaj4vth1krcb2xnVite exposes only variables prefixed with VITE_ to the browser bundle, which is why the name matters. Declare it once so every read is a string rather than any:
// src/avsb-env.d.tsdeclare global { interface ImportMetaEnv { readonly VITE_AVSB_SDK_KEY: string } interface ImportMeta { readonly env: ImportMetaEnv }}export {}The client checks the shape of the key when it is constructed. A pasted dashboard URL, a truncated copy, a personal access token, or a service token logs one actionable error naming what it got and where the real key lives.
2. Mount one provider
<!-- src/App.svelte --><script lang="ts"> import AvsbProvider from '@avsbhq/svelte/AvsbProvider.svelte' import Home from './Home.svelte'</script><AvsbProvider sdkKey={import.meta.env.VITE_AVSB_SDK_KEY} context={{ kind: 'user', key: 'anonymous' }}> <Home /></AvsbProvider>Mode A is an sdkKey, plus any other AvsbClientOptions field (context, bootstrap, pollingInterval, logLevel, and the rest, all flat as props). The provider builds the client and closes it when the component is destroyed. Mode B is a client you built: you own its lifetime and the provider never closes it. Passing neither throws during initialisation with a message naming both fixes, rather than rendering a tree where every flag is silently the default.
Without context the visitor gets a persisted anonymous id, which is fine for anonymous experiments but cannot target signed-in attributes.
AvsbProvider.svelte ships as raw source through its own export path, so your Vite Svelte plugin compiles it with your app's settings. The component is a wrapper around createAvsbContext, so the two cannot drift apart.
If you would rather set the context from a layout script than mount a component, call createAvsbContext during that component's initialisation and hand cleanup to onDestroy:
<!-- src/Root.svelte --><script lang="ts"> import { createAvsbContext } from '@avsbhq/svelte' import { onDestroy } from 'svelte' const { cleanup } = createAvsbContext({ sdkKey: import.meta.env.VITE_AVSB_SDK_KEY, context: { kind: 'user', key: 'anonymous' }, }) onDestroy(cleanup)</script>createAvsbContext calls Svelte's setContext, so it has the same rule Svelte does: during component initialisation, before onMount. Outside a component (a plain module, a test, a helper) use buildAvsbContext, which does everything except touch Svelte's context and hands you the contextValue to pass around yourself.
Nesting a second provider inside another shadows the outer one for everything below it: two clients, two visitor ids, two event queues, and identify calls that reach only one of them. The provider logs a warning in development when it detects this. Mount one provider at the root unless you deliberately want an isolated subtree.
3. Read a flag
<!-- src/components/HeroBanner.svelte --><script lang="ts"> import { stringFlag, getExposure } from '@avsbhq/svelte' import { onMount } from 'svelte' const heroVariant = stringFlag('homepage_hero', 'control') const expose = getExposure() onMount(() => expose('homepage_hero'))</script>{#if $heroVariant.value === 'variant-a'} <HeroBannerVariantA />{:else if $heroVariant.value === 'variant-b'} <HeroBannerVariantB />{:else} <HeroBannerControl />{/if}Every store is a Svelte Readable, so $heroVariant is the current Flag<T>. It updates whenever the value changes: after a datafile poll, after identify, or after a runtime override. A default value is always required, and it is what you get before the SDK is ready and when the key is unknown.
| Store factory | Emits |
|---|---|
flag<T>(key, default, ctx?) | Flag<T> |
flagValue<T>(key, default, ctx?) | T, the value on its own |
boolFlag(key, default, ctx?) | Flag<boolean> |
stringFlag(key, default, ctx?) | Flag<string> |
numberFlag(key, default, ctx?) | Flag<number> |
jsonFlag<T>(key, default, ctx?) | Flag<T> |
allFlags(ctx?) | Record<string, Flag> |
flagReady(ctx?) | boolean |
avsbStatus(ctx?) | { status, error, degraded } |
Every factory takes an optional ctx as its last argument. Omit it inside a component and the provider context is read for you. Pass it anywhere a component's context is not available, which is what makes these usable from a plain module or a test.
Flag<T> carries the decision, not just the value: value, variationKey (string | null), source, ruleId (string | null), ruleType, reasons (string[], never null), evaluatedAt, durationMicros, plus isEnabled() and exists().
source tells you which of datafileOverride, runtimeOverride, sticky, rule, holdout, bandit, default, disabled, not_found, or not_ready produced the value. exists() is false for not_found and not_ready.
The typed reads check the value against the type the platform declared for the flag. On a mismatch nothing throws: the SDK logs one warning naming the flag and the getter, then returns your default with source: 'not_found'. jsonFlag<T> does not validate the shape of T at runtime, so validate the payload yourself if it crosses a trust boundary.
Runes, if you prefer them
<!-- src/components/Checkout.svelte --><script lang="ts"> import { boolFlagState } from '@avsbhq/svelte/runes' const checkout = boolFlagState('new_checkout_flow', false)</script>{#if checkout.current.isEnabled()} <NewCheckout />{:else} <LegacyCheckout />{/if}@avsbhq/svelte/runes ships flagState, boolFlagState, stringFlagState, numberFlagState, jsonFlagState, allFlagsState, and avsbStatusState. Each returns an object with a single current property. Both layers share one subscription underneath, so they can never report different values. Pick per file: stores if your codebase is store-shaped, runes if it is runes-shaped.
The runes entry point ships as TypeScript source, because runes have to be compiled by your app's Svelte compiler. That is why it is a separate import path: the main barrel never pulls uncompiled runes into a build that cannot handle them.
Runes accessors need an effect owner, so they work in a component's <script> or in a .svelte.ts module a component instantiates. Called from a plain module at import time you get the first value and no updates. Own the effect yourself in that case, with $effect.root. Passing ctx covers the provider context, not the effect owner: those are two separate requirements, and only the stores are free of the second one.
4. Exposures: what lands in your results
Reading a flag never records an exposure. That is deliberate: an exposure means "this visitor saw this decision", and a component can re-render for reasons that have nothing to do with the visitor.
<script lang="ts"> import { getExposure } from '@avsbhq/svelte' import { onMount } from 'svelte' const expose = getExposure() onMount(() => expose('new_checkout_flow'))</script>If the SDK is not ready yet the exposure is deferred to the moment it is, rather than dropped. Only decisions that belong in an experiment (A/B rules, holdouts, bandits) produce an event, so a plain rollout has nothing to record.
expose(key) returns a cancel function for that deferred case, and onMount hands whatever you return to onDestroy, so the one-liner above already cancels itself. A component destroyed before the SDK was ready does not record an exposure for a decision nobody is looking at any more.
The get* prefix is not decoration. Svelte context can only be read while a component is initialising, so getExposure, getTrack, getIdentify, getAlias, getReset, and getAvsbClient are called at the top of <script> and return a function you use later, in a handler or in onMount.
For side effects that are not rendering, subscribe instead:
// src/lib/checkoutAnalytics.tsimport { subscribeFlag, type AvsbContextValue } from '@avsbhq/svelte'export function watchCheckoutVariant(ctx: AvsbContextValue): () => void { return subscribeFlag( 'new_checkout_flow', (flag) => { myAnalytics.track('variant_assigned', { variant: flag.variationKey }) }, ctx )}subscribeFlag returns its unsubscribe function. Inside a component, drop the ctx argument and pass the result to onDestroy.
5. Track a conversion
<!-- src/components/PurchaseButton.svelte --><script lang="ts"> import { getTrack } from '@avsbhq/svelte' const track = getTrack() // `revenue` is money in the project currency, in major units (49.99, not // 4999). `value` is a plain quantity: items in a cart, seats on a plan. function completePurchase(orderTotal: number): void { track('purchase', { revenue: orderTotal }) }</script><button type="button" onclick={() => completePurchase(49.99)}> Complete purchase</button>Svelte 5 uses onclick rather than on:click. Events tracked before the SDK is ready are held (bounded) and flushed as soon as it is ready.
properties on a track payload travel with exposure events only. The conversion pipeline stores the event name, revenue, value, and timing, so segmenting a metric on a track property is not available yet.
6. Identify a visitor
Anonymous visitors get a persisted id (localStorage, with a cookie fallback), so a returning visitor buckets into the same variation instead of being re-randomised on every load. If the A vs B web snippet is on the same page, the SDK adopts its visitor id, so flag exposures and web experiment exposures join to one visitor.
<!-- src/components/SessionGate.svelte --><script lang="ts"> import { getAvsbClient, getIdentify, getAlias, getReset } from '@avsbhq/svelte' const client = getAvsbClient() const identify = getIdentify() const alias = getAlias() const reset = getReset() function onSignIn(user: { id: string; email: string; plan: string }): void { const context = client.getContext() const anonymousKey = 'key' in context ? String(context['key']) : '' // Same person, two sessions: this is what lets results stitch them. alias({ kind: 'user', key: anonymousKey }, { kind: 'user', key: user.id }) identify({ kind: 'user', key: user.id, email: user.email, plan: user.plan }) } function onSignOut(): void { reset() // new anonymous identity, runtime overrides cleared }</script>getAvsbClient() is never null: a provider either has a client or throws. It is there for the things the stores do not cover (runtime overrides, flush(), refresh(), updateAttributes()). Prefer the stores for reading flags, because a direct client.getFlag in markup fires an exposure on every render.
alias is synchronous: it queues one event and returns. There is nothing to await, and it does not rewrite assignments already made. To carry bucketing across a login, evaluate with the same context key on both sides.
Multi-context
// src/lib/identity.tsimport type { AvsbContextValue } from '@avsbhq/svelte'export function identifyOrgMember( ctx: AvsbContextValue, userId: string, orgId: string): void { ctx.client.identify({ kind: 'multi', user: { kind: 'user', key: userId, plan: 'pro' }, organization: { kind: 'organization', key: orgId, tier: 'enterprise' }, })}A rule can bucket on user.key while matching an audience condition on organization.tier.
7. Reading the context directly
The provider stores one value on Svelte's context under a symbol the package exports, and that value is not the client:
// src/lib/avsbDebug.tsimport { AVSB_CONTEXT_KEY, hasAvsbContext, useAvsbContext } from '@avsbhq/svelte'import type { AvsbContextValue } from '@avsbhq/svelte'// The slot the provider writes to. Exported so a custom provider can set the// same one. You never pass a string.export const contextKey: symbol = AVSB_CONTEXT_KEY// Call both of these during component initialisation, the same rule// Svelte's own getContext has.export function readAvsbContext(): AvsbContextValue { return useAvsbContext()}export function isProviderMounted(): boolean { return hasAvsbContext()}AvsbContextValue is { client, status, error, degraded, onStatusChange }. status, error, and degraded are live getters, and onStatusChange(listener) returns an unsubscribe function. That is what makes flagReady and avsbStatus real stores rather than a value frozen at mount.
Both useAvsbContext() and hasAvsbContext() read Svelte's context, so they can only be called during component initialisation. Outside a component, pass a contextValue from buildAvsbContext as the last argument to any store factory instead.
There is no 'avsb-client' string context key, and the stored value has never been the client. An earlier version of this page showed getContext('avsb-client'), which reads an empty slot and returns undefined. Use getAvsbClient() for the client, or useAvsbContext() for the whole context value.
8. Readiness and failure
<!-- src/AppShell.svelte --><script lang="ts"> import { avsbStatus } from '@avsbhq/svelte' const status = avsbStatus()</script>{#if $status.status === 'loading'} <Skeleton />{:else} {#if $status.degraded} <StaleDataNotice message={$status.error?.message} /> {/if} <App />{/if}'loading': no datafile yet and nothing cached.'ready': flags answer. A degraded client is ready: a refresh failed while a cached datafile is being served, so values work and may be stale.errorsays why.'error': nothing could be loaded. Every flag returns the default you passed, anderror.messagenames the HTTP status, the URL tried, and the fix.
flagReady() is the one-boolean version when all you need is a gate. The default logger writes to the console at warn level in development and is silent in production. Pass logLevel="silent", or any other AvsbClientOptions field, straight to the provider.
9. Shutdown and the page lifecycle
In Mode A the provider closes the client when the component is destroyed, which flushes whatever events are queued. In a production SPA that provider is never destroyed, so in practice this fires during development hot-module replacement and when a test tears the app down.
Nothing closes the client when the visitor closes the tab, and nothing needs to. Two page-lifecycle behaviours cover it, both inside @avsbhq/browser:
- The event queue flushes itself whenever the page becomes hidden, on
visibilitychange, preferringnavigator.sendBeaconbecause a retry loop cannot outlive an unload. - Datafile polling pauses while the tab is hidden and refetches when the window regains focus.
There is no beforeunload listener, on purpose: registering one costs you the browser's back/forward cache.
In Mode B you own the client, so you close it:
// src/lib/avsbClient.tsimport { AvsbClient } from '@avsbhq/browser'export const client = new AvsbClient({ sdkKey: import.meta.env.VITE_AVSB_SDK_KEY, context: { kind: 'user', key: 'anonymous' },})export async function shutdown(): Promise<void> { await client.close() // flushes internally}10. Testing
@avsbhq/svelte/testing builds the real provider context around a real AvsbClient whose datafile comes from your flags map, with the network, the cache, and logging switched off. Your code exercises the same evaluator, the same snapshot caching, and the same exposure rules as production.
Outside a component, build the context value and pass it to any store:
// src/lib/checkout.test.tsimport { createTestContextValue, createTestDatafile } from '@avsbhq/svelte/testing'import { boolFlag } from '@avsbhq/svelte'import { get } from 'svelte/store'const { contextValue, client } = createTestContextValue({ flags: { 'new_checkout_flow': true }, context: { kind: 'user', key: 'u_1' },})expect(get(boolFlag('new_checkout_flow', false, contextValue)).isEnabled()).toBe(true)// Publish a new datafile and watch subscribers wake up.client.applyDatafileBootstrap( createTestDatafile({ 'new_checkout_flow': false }, { publishedAt: '2024-06-01T00:00:00.000Z' }))// Close a client that tracked events, so its flush timer does not outlive the test.await client.close()Inside a component test, set the real context on the component tree instead. Call createAvsbTestContext during a wrapper component's initialisation, exactly as the provider does:
// Inside a test wrapper component's <script>import { createAvsbTestContext } from '@avsbhq/svelte/testing'createAvsbTestContext({ flags: { 'new_checkout_flow': true } })Both helpers take { flags?, context? }, both return { contextValue, client, cleanup }, and each entry in flags becomes a flag that is fully rolled out, so isEnabled() is true for truthy values and reads record exposures exactly as an A/B rule would. createTestClient and createTestDatafile are re-exported from the same entry point, so the fixture a Svelte test builds is the same one every other SDK's tests build.
When you would rather drive values by hand, or assert on what your code asked for, @avsbhq/test ships a mock with the same surface. flagsFromTestData is the bridge from the fluent builder to the options the mock takes:
// src/lib/hero.test.tsimport { createMockClient, flagsFromTestData, TestData } from '@avsbhq/test'const entry = TestData.flag('homepage_hero') .stringFlag() .variationForUser('u_paying', 'variant-a') .fallthroughVariation('control') .build()const mock = createMockClient(flagsFromTestData([entry]))expect(mock.getStringFlag('homepage_hero', 'control').value).toBe('control')mock.identify({ kind: 'user', key: 'u_paying' })expect(mock.getStringFlag('homepage_hero', 'control').value).toBe('variant-a')flags is a map of flag key to value, not an array of built entries, and no test helper takes a Map of Svelte context entries. Earlier versions of this page showed createMockClient({ flags: [entry] }) and a context: new Map([['avsb-client', client]]) render option. Neither typechecks, and the second sets a context slot nothing reads.
11. SvelteKit
Server-side evaluation belongs on its own page, because it is a different set of moving parts: a server client, a handle hook, request-scoped locals, and a bootstrap datafile passed through load so the browser starts ready.
See SvelteKit for withAvsbHooks, locals.avsb, exposure rules for server-rendered markup, and CDN caching.
The one thing worth knowing here: the provider's bootstrap prop takes a datafile, the same document the CDN serves, not a map of evaluated values. That is what lets it travel through load data into the provider untouched.
12. Troubleshooting
| What you see | Why |
|---|---|
| Every flag is its default | No datafile yet. Gate on flagReady(), or read avsbStatus() for the error. |
| A store throws about the provider | It was called outside a component under the provider. Pass a contextValue as the last argument, or mount the provider. |
getContext('avsb-client') returns undefined | That key does not exist. The context key is the exported AVSB_CONTEXT_KEY symbol and its value is an AvsbContextValue. Use getAvsbClient(). |
| A runes accessor never updates | It was called at import time in a plain module, so no effect owner exists. Call it in a component, in a .svelte.ts module, or inside $effect.root. |
on:click does nothing | Svelte 5 uses onclick. |
| Exposures are missing from results | A read alone records nothing. Call getExposure()(key) where the variation is shown. |
| Two visitor ids in one session | Two providers are mounted. Check for a nested provider. |
source is not_found for a flag you can see in the dashboard | The name does not match, or the flag's declared type differs from the store you used. |
What's next
- SvelteKit integration
- Multi-context identity
- SDK installation: the browser client
@avsbhq/sveltewraps,Flag<T>, and every evaluation source. @avsbhq/svelteon npm: the package README, with every export.- Credentials: the four A vs B credentials and which one to reach for.