Vite + Solid

This guide wires A vs B into a Vite project running Solid 1.8 or later: one provider at the root, signal-based flag reads that only re-run the call sites reading them, exposures recorded where the visitor actually sees a variation, and tests that run the real evaluator.

1

Install

One package. @avsbhq/solid 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> around your root component.

4

Read a flag

createBoolFlag, createStringFlag, createNumberFlag, createJsonFlag, or createFlag for any type.

5

Record the exposure

createExposure in the component that shows the variation. Reads record nothing.

6

Track a conversion

useTrack sends the events your metrics count.

Shell
npm install @avsbhq/solid
Shell1 line

@avsbhq/browser is a dependency of @avsbhq/solid, so it arrives with it. @solidjs/start 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.

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

TypeScript React
// src/index.tsximport { render } from 'solid-js/web'import { AvsbProvider } from '@avsbhq/solid'import App from './App'render(  () => (    <AvsbProvider      sdkKey={import.meta.env.VITE_AVSB_SDK_KEY}      context={{ kind: 'user', key: 'anonymous' }}    >      <App />    </AvsbProvider>  ),  document.getElementById('root') as HTMLElement)
TypeScript React16 lines

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 its owner is disposed. Mode B is a client you built: you own its lifetime and the provider never closes it. Passing neither throws while the component is created, 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.

Info

The package's solid export condition points at TypeScript source, which is what Solid libraries are supposed to ship: vite-plugin-solid compiles the JSX with your app's own settings, so there is one reactive runtime and one JSX transform. The bundled output is the fallback for toolchains that do not read the condition.


3. Read a flag

TypeScript React
// src/components/CheckoutButton.tsximport { Show } from 'solid-js'import type { Accessor, JSX } from 'solid-js'import { createBoolFlag, createExposure, useTrack } from '@avsbhq/solid'import type { Flag } from '@avsbhq/solid'export default function CheckoutButton(): JSX.Element {  const checkout: Accessor<Flag<boolean>> = createBoolFlag('new_checkout_flow', false)  const track = useTrack()  // The visitor is about to see this decision, so record it once.  createExposure('new_checkout_flow')  return (    <Show when={checkout().isEnabled()} fallback={<LegacyCheckout />}>      <button type="button" onClick={() => track('checkout_started', { revenue: 99 })}>        Start checkout      </button>    </Show>  )}
TypeScript React21 lines

Every read returns an Accessor<Flag<T>>, so only the call sites that read checkout() re-run when the value changes, not the parent component. A default value is always required, and it is what you get before the SDK is ready and when the key is unknown.

FunctionReturns
createFlag<T>(key, default)Accessor<Flag<T>>
createFlagValue<T>(key, default)Accessor<T>, the value on its own
createBoolFlag(key, default)Accessor<Flag<boolean>>
createStringFlag(key, default)Accessor<Flag<string>>
createNumberFlag(key, default)Accessor<Flag<number>>
createJsonFlag<T>(key, default)Accessor<Flag<T>>
createAllFlags()Accessor<Record<string, Flag>>
createFlagReady()Accessor<boolean>
useAvsbStatus(){ status, error, degraded }, each an Accessor

The naming rule is not decoration. create* registers something reactive (a signal, an effect, a subscription cleaned up with its owner), so it belongs in a component body. use* reads the provider context and hands back a plain function. That is why exposure is createExposure, which schedules work, while tracking is useTrack, which only gives you something to call.

The key accepts a string or an Accessor<string>, so a read can follow a reactive key:

TypeScript
// Inside a component under the provider.import { createSignal } from 'solid-js'import { createAllFlags, createFlag } from '@avsbhq/solid'const [selected, setSelected] = createSignal('feature-a')// Re-subscribes whenever `selected` changes.const picked = createFlag(selected, false)// Every flag at once, for a debug panel. It re-runs on any flag change, so// prefer a single-flag accessor in normal components.const everything = createAllFlags()
TypeScript12 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'. createJsonFlag<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 never record an exposure. That is deliberate: an exposure means "this visitor saw this decision", and a component can re-run for reasons that have nothing to do with the visitor.

createExposure(key) fires once, after the first render, never during it and never on the server. If the SDK is not ready yet the exposure is deferred to the moment it is, and cancelled if the owner is disposed first. Only decisions that belong in an experiment (A/B rules, holdouts, bandits) produce an event, so a plain rollout has nothing to record.

It is also what makes a server-rendered variant visible in results: if the value arrived as a prop from the server, call createExposure where it is rendered.

Warning

Do not pair createExposure with your own onMount hook for the same flag. onMount is createEffect plus untrack in Solid, and effects flush in creation order, so the pair fires the exposure twice on every mount and doubles every count in your results.

For side effects that are not rendering, subscribe instead:

TypeScript
// src/lib/checkoutAnalytics.tsimport { createFlagSubscription } from '@avsbhq/solid'// Call this inside a component under the provider: it registers an effect,// so it needs an owner to be cleaned up with.export function trackVariantAssignment(): void {  createFlagSubscription('new_checkout_flow', (flag) => {    myAnalytics.track('variant_assigned', { variant: flag.variationKey })  })}
TypeScript10 lines

The flag handed to the callback is a render-safe read: it records no exposure.


5. Track a conversion

TypeScript
// src/lib/purchaseTracking.tsimport { useTrack } from '@avsbhq/solid'// Call this inside a component under the provider, then use the function it// returns from an event handler.export function createPurchaseTracker(): (orderTotal: number) => void {  const track = useTrack()  // `revenue` is money in the project currency, in major units (79.00, not  // 7900). `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 })  }}
TypeScript16 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/lib/session.tsimport { useAlias, useAvsbClient, useIdentify, useReset } from '@avsbhq/solid'interface SignedInUser {  id: string  email: string  plan: string}// Call this inside a component under the provider.export function createSession(): {  signIn: (user: SignedInUser) => void  signOut: () => void} {  // Never null: a provider either has a client or throws.  const client = useAvsbClient()  const identify = useIdentify()  const alias = useAlias()  const reset = useReset()  return {    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 })    },    signOut: (): void => {      reset() // new anonymous identity, runtime overrides cleared    },  }}
TypeScript36 lines

Call identify from an event handler or an effect, never from the component body. The body runs during render, and identifying there re-evaluates every flag mid-render on every run.

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.

useAvsbClient() is there for the things the accessors do not cover: runtime overrides, flush(), refresh(), updateAttributes(). Prefer createFlag and friends for reading flags, because a direct client.getFlag in a component body fires an exposure on every render.

Multi-context

TypeScript
// src/lib/orgIdentity.tsimport { useIdentify } from '@avsbhq/solid'export function createOrgIdentity(): (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

TypeScript
// Inside a component under the provider.import { useAvsbStatus } from '@avsbhq/solid'const { status, error, degraded } = useAvsbStatus()status() // 'loading' | 'ready' | 'error'degraded() // true while a cached datafile is served after a failed refresherror()?.message // why the last attempt failed
TypeScript8 lines

Render a skeleton while status() is 'loading', a stale-data notice while degraded() is true, and your app otherwise. Solid's <Show when={status() !== 'loading'} fallback={<Skeleton />}> is the usual shape.

  • '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.

createFlagReady() 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.


8. SolidStart

The server helper binds your server client to each request's visitor and puts three values on event.locals. Wire it in src/middleware.ts:

TypeScript
// src/middleware.tsimport { createMiddleware } from '@solidjs/start/middleware'import { AvsbServer } from '@avsbhq/node'import { withAvsbServerContext } from '@avsbhq/solid/solid-start'const avsb = new AvsbServer({ sdkKey: process.env['AVSB_SDK_KEY'] ?? '' })export default createMiddleware({  onRequest: [    withAvsbServerContext(() => {}, {      serverClient: avsb,      resolveContext: (event) => ({        kind: 'user',        key: event.request.headers.get('x-user-id') ?? 'anonymous',      }),    }),  ],})
TypeScript18 lines

withAvsbServerContext(handler, options) takes the handler first and its options second. The options are serverClient, your AvsbServer from @avsbhq/node, and resolveContext, which builds the evaluation context for the request or returns null to skip A vs B entirely, for static assets and health checks. There is no sdkKey option: the server client already holds the key.

The inner handler returns nothing, on purpose. On the runner underneath SolidStart, an onRequest handler that returns a value is taken as the response for that request, so returning the event would short-circuit the route it was meant to pass through. Return a Response only when you mean to answer the request yourself.

Three values land on event.locals, flat:

LocalWhat it is
event.locals.avsbContextThe EvalContext this request was evaluated for, or null when skipped.
event.locals.avsbClientAn AvsbBoundClient with that context already bound, or null when skipped.
event.locals.avsbBootstrapThe datafile, ready to serialise into the page, or null when the server client has not loaded one yet.

Declare them once so TypeScript knows the shape:

TypeScript
// src/global.d.ts// docs-example: not typechecked here, because @solidjs/start is not installed// in this repo, so the module augmentation has no module to attach to.import type { AvsbServerLocals } from '@avsbhq/solid/solid-start'declare module '@solidjs/start/server' {  interface RequestEventLocals extends AvsbServerLocals {}}
TypeScript8 lines

AvsbBoundClient reads flags without a context argument (getFlag, getBoolFlag, getStringFlag, getNumberFlag, getJsonFlag, getAllFlags), and manualExposure(flagKey) records that the visitor was shown a decision when the page is rendered without hydration. getAvsbBootstrap(serverClient) returns the same datafile if you want it outside the middleware.

Serialise avsbBootstrap into the page and hand it to the provider, so the browser client starts ready:

TypeScript React
// src/app.tsximport type { JSX } from 'solid-js'import { AvsbProvider } from '@avsbhq/solid'import type { FlagDatafile } from '@avsbhq/solid'import App from './App'// Read out of the page by your root route, from event.locals.avsbBootstrap.declare const bootstrapFromServer: FlagDatafile | undefinedexport default function Root(): JSX.Element {  return (    <AvsbProvider      sdkKey={import.meta.env.VITE_AVSB_SDK_KEY}      bootstrap={bootstrapFromServer}    >      <App />    </AvsbProvider>  )}
TypeScript React19 lines

With a bootstrap the browser client is ready on its first render: createFlagReady() is already true, no loading state appears, and the values match what the server rendered.

Info

bootstrap is the datafile, the same document the CDN serves, not a map of evaluated values. It is JSON-safe by construction, which is what lets it travel from the server into the provider untouched. An earlier helper serialised evaluated flags and called them a bootstrap, which crashed the browser client at construction.


9. Shutdown and the page lifecycle

In Mode A the provider closes the client when its owner is disposed, which flushes whatever events are queued. In a Vite SPA that happens during development hot-module replacement and when a test tears the tree 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/lib/avsbClient.tsimport { onCleanup } from 'solid-js'import { AvsbClient } from '@avsbhq/browser'export const client = new AvsbClient({  sdkKey: import.meta.env.VITE_AVSB_SDK_KEY,  context: { kind: 'user', key: 'anonymous' },})// Call inside a reactive owner: a component, or createRoot.export function registerShutdown(): void {  onCleanup(() => {    void client.close() // flushes internally  })}
TypeScript15 lines

10. Testing

@avsbhq/solid/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 accessors, the same evaluator, and the same exposure rules they use in production.

TypeScript React
// src/components/CheckoutButton.test.tsximport { render } from '@solidjs/testing-library'import { AvsbTestProvider } from '@avsbhq/solid/testing'import CheckoutButton from './CheckoutButton'render(() => (  <AvsbTestProvider flags={{ 'new_checkout_flow': true }}>    <CheckoutButton />  </AvsbTestProvider>))
TypeScript React10 lines

AvsbTestProvider takes flags, context, client, and children. Those first three names are the same in the React, Vue, and Svelte 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/lib/flags.test.tsimport { createTestClient, createTestDatafile } from '@avsbhq/solid/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
Info

@avsbhq/solid/testing carries its own copy of createTestClient and createTestDatafile rather than re-exporting them from @avsbhq/test, which is what the Vue and Svelte packages do. That is deliberate: this subpath ships raw source so your vite-plugin-solid compiles its JSX, and raw source shipped to you can only import what you installed. The two copies are kept byte-for-byte equivalent.

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/lib/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 this page 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 createFlagReady(), or read useAvsbStatus() for the error.
A create* or use* call throws about the providerIt ran outside a tree under <AvsbProvider>.
A flag value never updatesThe accessor was read once into a variable. Call checkout() at the point of use so the reactive read is tracked.
Exposure counts are exactly doubledcreateExposure was paired with an onMount hook for the same flag. Use createExposure alone.
event.locals.avsbClient is nullresolveContext returned null for that request, or the middleware is not in the onRequest chain.
Two reactive runtimes, or JSX that does not renderA toolchain that ignores the solid export condition compiled the package's JSX. Check that vite-plugin-solid is in your Vite config.
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?