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.
Install
One package. @avsbhq/solid 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> around your root component.
Read a flag
createBoolFlag, createStringFlag, createNumberFlag, createJsonFlag, or createFlag for any type.
Record the exposure
createExposure in the component that shows the variation. Reads record nothing.
Track a conversion
useTrack sends the events your metrics count.
npm install @avsbhq/solid@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.
# .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/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)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.
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.
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
// 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> )}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.
| Function | Returns |
|---|---|
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:
// 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()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'. 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.
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:
// 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 }) })}The flag handed to the callback is a render-safe read: it records no exposure.
5. Track a conversion
// 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 }) }}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/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 }, }}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
// 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' }, }) }}A rule can bucket on user.key while matching an audience condition on organization.tier.
7. Readiness and failure
// 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 failedRender 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, anderror()?.messagenames 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:
// 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', }), }), ],})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:
| Local | What it is |
|---|---|
event.locals.avsbContext | The EvalContext this request was evaluated for, or null when skipped. |
event.locals.avsbClient | An AvsbBoundClient with that context already bound, or null when skipped. |
event.locals.avsbBootstrap | The 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:
// 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 {}}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:
// 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> )}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.
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, 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 { 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 })}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.
// 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>))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:
// 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()@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:
// 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)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 see | Why |
|---|---|
| Every flag is its default | No datafile yet. Gate on createFlagReady(), or read useAvsbStatus() for the error. |
A create* or use* call throws about the provider | It ran outside a tree under <AvsbProvider>. |
| A flag value never updates | The accessor was read once into a variable. Call checkout() at the point of use so the reactive read is tracked. |
| Exposure counts are exactly doubled | createExposure was paired with an onMount hook for the same flag. Use createExposure alone. |
event.locals.avsbClient is null | resolveContext returned null for that request, or the middleware is not in the onRequest chain. |
| Two reactive runtimes, or JSX that does not render | A 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 dashboard | The name does not match, or the flag's declared type differs from the getter you used. |
What's next
- Multi-context identity
- Sticky bucketing
- SDK installation: the browser client
@avsbhq/solidwraps,Flag<T>, and every evaluation source. @avsbhq/solidon npm: the package README, with every export.- Credentials: the four A vs B credentials and which one to reach for.