Vite + Vue

This guide wires A vs B into a Vite + Vue 3 single-page app: one provider at the root, reactive flag reads in any component, exposures recorded where the visitor actually sees a variation, and tests that run the real evaluator instead of a mock that agrees with itself.

Vue 3.4 or later is required. For a Nuxt app use the @avsbhq/nuxt module instead, which wires the plugin, the SSR bootstrap, and auto-imports for you.

1

Install

One package. @avsbhq/vue brings @avsbhq/browser with it.

2

Copy your SDK key

Open Environments in the project sidebar and expose the key as a VITE_ variable.

3

Mount one provider

<AvsbProvider> at the root of App.vue, or app.use(AvsbPlugin) in main.ts.

4

Read a flag

useBoolFlag, useStringFlag, useNumberFlag, useJsonFlag, or useFlag for any type.

5

Record the exposure

useExposure in the component that shows the variation. Reads during render record nothing.

6

Track a conversion

useTrack sends the events your metrics count.

Shell
npm install @avsbhq/vue
Shell1 line

@avsbhq/browser is a dependency of @avsbhq/vue, so it arrives with it. Install it separately only if you also build a client by hand.


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.

Shell
# .envVITE_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.

Vite 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:

TypeScript
// src/avsb-env.d.tsdeclare global {  interface ImportMetaEnv {    readonly VITE_AVSB_SDK_KEY: string  }  interface ImportMeta {    readonly env: ImportMetaEnv  }}export {}
TypeScript11 lines

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

The component form, at the root of your tree:

Vue
<!-- src/App.vue --><script setup lang="ts">import { AvsbProvider } from '@avsbhq/vue'const sdkKey = import.meta.env.VITE_AVSB_SDK_KEY</script><template>  <AvsbProvider :sdk-key="sdkKey" :context="{ kind: 'user', key: 'anonymous' }">    <RouterView />  </AvsbProvider></template>
Vue12 lines

The plugin form, if you would rather provide it at app level:

TypeScript
// src/main.tsimport { createApp } from 'vue'import { AvsbPlugin } from '@avsbhq/vue'import App from './App.vue'const app = createApp(App)app.use(AvsbPlugin, {  sdkKey: import.meta.env.VITE_AVSB_SDK_KEY,  context: { kind: 'user', key: 'anonymous' },})app.mount('#app')
TypeScript13 lines

Both forms have the same semantics. They differ in one place: the component keeps the remaining client options in a nested options prop, and the plugin takes them flat alongside sdkKey.

<AvsbProvider>app.use(AvsbPlugin, …)
SDK keysdk-key propsdkKey
Evaluation context:context propcontext
Server-fetched datafile:bootstrap propbootstrap
Polling, logging, caching:options propflat, alongside sdkKey
A client you built:client propclient
Closes the clienton unmounton app.unmount()

Mode A is an sdk-key: the provider builds the client and closes it when it goes away. Mode B is a client you built: you own its lifetime and the provider never closes it. Passing neither throws during setup 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.

Warning

Nesting a second <AvsbProvider> 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

Vue
<!-- src/components/CheckoutButton.vue --><script setup lang="ts">import { useBoolFlag, useExposure, useTrack } from '@avsbhq/vue'const checkout = useBoolFlag('new_checkout_flow', false)const track = useTrack()// The visitor is about to see this decision, so record it once.useExposure('new_checkout_flow')</script><template>  <button type="button" @click="track('checkout_started', { revenue: 99 })">    {{ checkout.value ? 'Start checkout (new)' : 'Buy now' }}  </button></template>
Vue16 lines

Every read returns a ComputedRef<Flag<T>>, and a default value is always required: it is what you get before the SDK is ready and when the key is unknown.

ComposableReturns
useFlag<T>(key, default)ComputedRef<Flag<T>>
useFlagValue<T>(key, default)ComputedRef<T>, the value on its own
useBoolFlag(key, default)ComputedRef<Flag<boolean>>
useStringFlag(key, default)ComputedRef<Flag<string>>
useNumberFlag(key, default)ComputedRef<Flag<number>>
useJsonFlag<T>(key, default)ComputedRef<Flag<T>>
useAllFlags()ComputedRef<Record<string, Flag>>

The key accepts a string, a Ref<string>, or a ComputedRef<string>, so a read can follow a reactive key and re-subscribe when it changes.

Why .value.value

Vue unwraps a ref with .value, and the flag's own field is also called value. In a template Vue does the unwrapping, so it is one .value. In <script setup> it is two. Two ways to make that read better:

TypeScript
// src/composables/useCheckout.tsimport { computed, type ComputedRef, type Ref } from 'vue'import { useAllFlags, useBoolFlag, useFlag, useFlagValue } from '@avsbhq/vue'import type { Flag } from '@avsbhq/vue'// 1. Derive a plain boolean once, at the top of setup.export function useCheckout(): ComputedRef<boolean> {  const checkout = useBoolFlag('new_checkout_flow', false)  return computed(() => checkout.value.isEnabled())}// 2. Ask for the value instead of the Flag, and there is only ever one .value.export function useHeadline(): ComputedRef<string> {  return useFlagValue('homepage_headline', 'Ship faster')}// A reactive key re-subscribes whenever the ref changes.export function useSelectedFlag(selected: Ref<string>): ComputedRef<Flag<boolean>> {  return useFlag(selected, false)}// Every flag at once, for a debug panel. It re-computes on any flag change,// so prefer a single-flag read in normal components.export function useFlagDump(): ComputedRef<Record<string, Flag>> {  return useAllFlags()}
TypeScript26 lines

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.

Info

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'. useJsonFlag<T> does not validate the shape of T at runtime, so validate the payload yourself if it crosses a trust boundary.


4. Exposures: what lands in your results

Reads during render never record an exposure. That is deliberate: an exposure means "this visitor saw this decision", and Vue can re-render a component any number of times for reasons that have nothing to do with the visitor.

useExposure(key) fires on mount, once, or as soon as the SDK becomes ready if it is not yet. It is also what makes a server-rendered variant visible in results: if the value arrived as a prop, call useExposure where it is rendered. Only decisions that belong in an experiment (A/B rules, holdouts, bandits) produce an event, so a plain rollout has nothing to record.

For side effects that are not rendering, subscribe instead:

TypeScript
// src/composables/useCheckoutAnalytics.tsimport { useFlagSubscription } from '@avsbhq/vue'export function useCheckoutAnalytics(): void {  useFlagSubscription('new_checkout_flow', (flag) => {    myAnalytics.track('variant_assigned', { variant: flag.variationKey })  })}
TypeScript8 lines

5. Track a conversion

TypeScript
// src/composables/usePurchaseTracking.tsimport { useTrack } from '@avsbhq/vue'export function usePurchaseTracking(): (orderTotal: number) => void {  const track = useTrack()  // `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.  // They are separate columns end to end, so one conversion can carry either  // or both.  return (orderTotal: number): void => {    track('purchase', { revenue: orderTotal })  }}
TypeScript14 lines

Events tracked before the SDK is ready are held (bounded) and flushed as soon as it is ready.

Info

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.

TypeScript
// src/composables/useSession.tsimport { useAlias, useAvsbClient, useIdentify, useReset } from '@avsbhq/vue'interface SignedInUser {  id: string  email: string  plan: string}export function useSession(): {  signIn: (user: SignedInUser) => void  signOut: () => void} {  const client = useAvsbClient()  const identify = useIdentify()  const alias = useAlias()  const reset = useReset()  function signIn(user: SignedInUser): void {    const context = client.getContext()    const anonymousKey: string = '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 })    // From here on, flags evaluate for the signed-in user.    identify({ kind: 'user', key: user.id, email: user.email, plan: user.plan })  }  function signOut(): void {    reset() // new anonymous identity, runtime overrides cleared  }  return { signIn, signOut }}
TypeScript35 lines

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

TypeScript
// src/composables/useOrgIdentity.tsimport { useIdentify } from '@avsbhq/vue'export function useOrgIdentity(): (userId: string, orgId: string) => void {  const identify = useIdentify()  return (userId: string, orgId: string): void => {    identify({      kind: 'multi',      user: { kind: 'user', key: userId, plan: 'pro' },      organization: { kind: 'organization', key: orgId, tier: 'enterprise' },    })  }}
TypeScript14 lines

A rule can bucket on user.key while matching an audience condition on organization.tier.


7. Readiness and failure

Vue
<!-- src/components/AppShell.vue --><script setup lang="ts">import { useAvsbStatus } from '@avsbhq/vue'const { status, error, degraded } = useAvsbStatus()</script><template>  <SkeletonApp v-if="status === 'loading'" />  <template v-else>    <StaleDataNotice v-if="degraded" :message="error?.message" />    <MyApp />  </template></template>
Vue14 lines

useAvsbStatus() returns status, error, and degraded, each a ComputedRef.

  • '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. error says why.
  • 'error': nothing could be loaded. Every flag returns the default you passed, and error.message names the HTTP status, the URL tried, and the fix.

useFlagReady() 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. Change it through the provider's options prop, for example :options="{ logLevel: 'debug' }".


8. Pinia (optional)

The Pinia store lives on its own entry point. Importing it from the main barrel used to pull pinia into every Vue app whether or not it used Pinia, which broke module resolution for everyone else, so pinia is now an optional peer dependency you install only if you import @avsbhq/vue/pinia.

TypeScript
// Inside a component under <AvsbProvider>, or in an app that installed AvsbPlugin.import { useAvsbStore } from '@avsbhq/vue/pinia'const avsb = useAvsbStore()avsb.status // 'loading' | 'ready' | 'error'avsb.degraded // booleanavsb.flags // Record<string, Flag>, refreshed on any flag changeavsb.client // the underlying clientavsb.identify({ kind: 'user', key: 'u_1' })avsb.alias({ kind: 'user', key: 'anon_1' }, { kind: 'user', key: 'u_1' })avsb.track('checkout_started', { revenue: 99 })avsb.reset()
TypeScript13 lines

The store reflects the provider and never creates a client: flags is a live copy of every flag, refreshed whenever any flag changes, and the actions call straight through. The first component that calls useAvsbStore() binds it, so that call has to happen somewhere under <AvsbProvider>, or in an app that installed AvsbPlugin.


9. Shutdown and the page lifecycle

In Mode A the provider closes the client when it unmounts, and the plugin closes it on app.unmount(). Closing flushes whatever events are queued. In a Vite SPA that happens 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, preferring navigator.sendBeacon because 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:

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

10. Testing

@avsbhq/vue/testing gives you the real <AvsbProvider> in Mode B around a real AvsbClient whose datafile is built from your flags map, with the network, the cache, and logging switched off. Components under test use the same composables, the same evaluator, and the same exposure rules they use in production.

TypeScript
// src/components/CheckoutButton.test.tsimport { mount } from '@vue/test-utils'import { AvsbTestProvider } from '@avsbhq/vue/testing'import CheckoutButton from './CheckoutButton.vue'const wrapper = mount(AvsbTestProvider, {  props: { flags: { 'new_checkout_flow': true } },  slots: { default: CheckoutButton },})expect(wrapper.text()).toContain('Start checkout (new)')
TypeScript11 lines

AvsbTestProvider takes flags, context, and client. Those three names are the same in the React, Svelte, and Solid wrappers, so the shape is learned once. 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.

Drive the client directly when that is simpler:

TypeScript
// src/flags.test.tsimport { createTestClient, createTestDatafile } from '@avsbhq/vue/testing'const client = createTestClient({  flags: { 'new_checkout_flow': true },  context: { kind: 'user', key: 'u_1' },})expect(client.getBoolFlag('new_checkout_flow', false).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()
TypeScript17 lines

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:

TypeScript
// src/checkout.test.tsimport { createMockClient, flagsFromTestData, TestData } from '@avsbhq/test'const entry = TestData.flag('new_checkout_flow')  .booleanFlag()  .variationForUser('u_paying', true)  .fallthroughVariation(false)  .build()const mock = createMockClient(flagsFromTestData([entry]))expect(mock.getBoolFlag('new_checkout_flow', false).value).toBe(false)mock.identify({ kind: 'user', key: 'u_paying' })expect(mock.getBoolFlag('new_checkout_flow', false).value).toBe(true)
TypeScript15 lines
Warning

flags is a map of flag key to value, not an array of built entries. An earlier version of these docs showed createMockClient({ flags: [entry] }), which does not typecheck and evaluated to defaults at runtime. Pass flagsFromTestData([entry]), or a plain map such as { flags: { 'new_checkout_flow': true } }.


11. Troubleshooting

What you seeWhy
Every flag is its defaultNo datafile yet. Gate on useFlagReady(), or check useAvsbStatus() for the error.
A composable throws about the providerIt was called outside a tree under <AvsbProvider>, or in an app that never installed AvsbPlugin.
flag.value is a Flag object, not the valueIn <script setup> it is flag.value.value. Use useFlagValue if one .value reads better.
Exposures are missing from resultsA read alone records nothing. Call useExposure(key) in the component that shows the variation.
Two visitor ids in one sessionTwo providers are mounted. Check for a nested <AvsbProvider>.
pinia cannot be resolvedThe store is on @avsbhq/vue/pinia, and pinia is an optional peer you have to install.
source is not_found for a flag you can see in the dashboardThe name does not match, or the flag's declared type differs from the getter you used.

What's next

Was this helpful?