SDK Installation
Feature flag projects use npm SDK packages rather than the web snippet. Pick the package that matches your app: each one wraps the same evaluator, so the concepts on this page are the same everywhere.
| Your app | Install | Guide |
|---|---|---|
| Vanilla JS or TypeScript in the browser | @avsbhq/browser | This page |
| React | @avsbhq/react | Vite + React |
| Next.js | @avsbhq/next | App Router, Pages Router |
| Vue | @avsbhq/vue | Vite + Vue |
| Nuxt | @avsbhq/nuxt | Nuxt |
| Svelte | @avsbhq/svelte | Vite + Svelte, SvelteKit |
| Solid | @avsbhq/solid | Vite + Solid |
| Angular | @avsbhq/angular | Angular CLI |
| React Native | @avsbhq/react-native | React Native |
| Node.js server | @avsbhq/node | Express, Fastify |
| Edge runtimes | @avsbhq/edge | Edge SDK |
Each framework package depends on the client it wraps, so one install is enough: npm install @avsbhq/react brings @avsbhq/browser with it.
Get your SDK key
Open Environments in your feature flag project sidebar and copy the key for the environment you are wiring up. The format is sdk_<environment>_<id>, for example sdk_production_ttqm0eaj4vth1krcb2xn. Every environment has its own key.
The client checks the shape of the key when you construct it. A key that does not look like an SDK key (a pasted dashboard URL, a truncated copy, a token) logs one actionable error naming what it got and where the real key lives, then carries on. The datafile request is the real test.
The browser SDK
npm install @avsbhq/browserConstruct the client once
import { AvsbClient } from '@avsbhq/browser'import type { Flag, InitResult } from '@avsbhq/browser'const client = new AvsbClient({ sdkKey: 'sdk_production_ttqm0eaj4vth1krcb2xn', context: { kind: 'user', key: 'u_123', plan: 'pro' },})const result: InitResult = await client.onReady()if (!result.success) { // Nothing could be loaded, so every read returns the default you pass. // result.error names the status, the URL, and the fix. console.warn(result.error?.message)}const checkout: Flag<boolean> = client.getBoolFlag('new_checkout_flow', false)Construct it once at application boot, not per page or per component.
The constructor starts loading immediately: it validates the key, resolves a stable visitor id, serves any cached datafile, and starts the network fetch. onReady() is how you wait for the result, not how you start it. Pass autoInit: false to build the client now and load later.
context is the one identity model. The old attributes and userIdAttribute options were deleted: two models meant every SDK and every page had to teach both, and the second could not express multi-context at all. The migration is mechanical: attributes: { userId: 'u_1', plan: 'pro' } becomes context: { kind: 'user', key: 'u_1', plan: 'pro' }.
Read flags
Every read returns a Flag<T> object and takes a default value. The default is what you get before the SDK is ready, when the flag key does not exist, and when the flag's declared type contradicts the getter you called.
const darkMode: Flag<boolean> = client.getBoolFlag('dark_mode', false)const theme: Flag<string> = client.getStringFlag('homepage_theme', 'default')const maxItems: Flag<number> = client.getNumberFlag('max_results', 25)interface ApiConfig { timeout: number retries: number}const config: Flag<ApiConfig> = client.getJsonFlag<ApiConfig>('api_config', { timeout: 5000, retries: 3,})const timeout: number = config.value.timeoutThe typed getters check the value against the type the platform declared for that flag. On a mismatch they never throw: one warning names the flag, the getter, and the declared type, and you get your default back with source: 'not_found'. getJsonFlag<T> checks only that the flag is declared as JSON, so validate the shape of T yourself if it crosses a trust boundary.
getFlag<T>(key, default) runs no runtime check and returns whatever the datafile says, typed as T. Prefer a typed getter when you know the type.
A flag key is lowercase letters, digits and underscores, starting with a letter, up to 100 characters: new_checkout_flow, never new-checkout-flow. The dashboard and the API refuse any other key, so a read of new-checkout-flow can never find a flag. When a read misses, the SDK logs it once for that key, and for a key the platform would refuse it names the spelling it would accept: did you mean new_checkout_flow?
Flag<T>, in full
import type { EvaluationSource } from '@avsbhq/browser'/** The four rule kinds a decision can come from. */type RuleType = 'targeted_delivery' | 'ab_test' | 'holdout' | 'bandit'interface FlagShape<T = unknown> { /** The variation value, typed against the default you passed. */ readonly value: T /** Variation key, null when the default was served or the flag is missing. */ readonly variationKey: string | null /** Why this value was produced. */ readonly source: EvaluationSource /** The rule that matched, null when none did. */ readonly ruleId: string | null readonly ruleType: RuleType | null /** Structured reasons for the decision. */ readonly reasons: string[] /** Milliseconds since the epoch, when this was evaluated. */ readonly evaluatedAt: number /** Microseconds spent in the evaluator. */ readonly durationMicros: number /** A real decision produced a truthy value. */ isEnabled(): boolean /** False for 'not_found' and for 'not_ready'. */ exists(): boolean}The object is frozen, and the same object comes back on every read until that flag's value actually changes.
Every evaluation source
type SourceUnion = | 'datafileOverride' | 'runtimeOverride' | 'sticky' | 'rule' | 'holdout' | 'bandit' | 'default' | 'not_found' | 'disabled' | 'not_ready'| Source | Meaning | isEnabled() | exists() |
|---|---|---|---|
datafileOverride | A per-user override configured in the dashboard matched. | value-dependent | true |
runtimeOverride | setOverrideForUser or setGlobalOverride matched. | value-dependent | true |
sticky | A previously stored assignment was reused. | value-dependent | true |
rule | A targeting rule or an A/B rule matched. | value-dependent | true |
holdout | The visitor is in a holdout. | value-dependent | true |
bandit | A bandit rule picked the variation. | value-dependent | true |
default | The flag exists, nothing matched, so its default variation was served. | false | true |
disabled | The flag exists but is switched off in this environment. | false | true |
not_found | The datafile loaded and does not contain this key. Check the spelling. | false | false |
not_ready | No datafile yet. Ask again after onReady(). | false | false |
"Value-dependent" means isEnabled() is Boolean(flag.value): a real decision was made, so the answer is whatever it produced.
Track conversions
client.track('purchase_completed', { revenue: 199.0 }) // moneyclient.track('items_added', { value: 3 }) // a quantityclient.track('checkout_started') // a countrevenue is money in decimal major units of the project currency (49.99, not 4999). value is a numeric metric value for average-value metrics (items, seats, seconds). They are two separate columns end to end, so one conversion can carry money, a quantity, or both.
Events are batched, flushed on an interval and on tab hide, retried on failure with backoff, and held (bounded) until the client is ready.
Identity
// Replace the whole context and re-bucket every flag.client.identify({ kind: 'user', key: 'u_123', plan: 'enterprise' })// Patch attributes on the current context, keeping the key.client.updateAttributes({ plan: 'enterprise' })// Stitch an anonymous session to the identified one, then switch.client.alias({ kind: 'user', key: 'anon_abc' }, { kind: 'user', key: 'u_123' })// Rotate to a NEW anonymous identity and clear runtime overrides.client.reset()Multi-context lets one evaluation see several subjects at once. A rule can bucket on user.key while matching an audience condition on organization.tier:
client.identify({ kind: 'multi', user: { kind: 'user', key: 'u_123', plan: 'pro' }, organization: { kind: 'organization', key: 'org_456', tier: 'enterprise' },})Reading during a render
Framework adapters read flags while rendering, which must never write analytics. Read-only reads return the same object until the value changes and fire no exposure:
const unsubscribe: () => void = client.subscribe('checkout_v2', () => { // this flag changed, or the SDK just became ready})const renderSafe: Flag<boolean> = client.getBoolFlag('checkout_v2', false, { readOnly: true })unsubscribe()Fire the exposure once, where the decision is actually shown, with a normal read. In React, that is useExposure.
All flags at once
const all: Record<string, Flag> = client.getAllFlags()const forSomeoneElse: Record<string, Flag> = client.getAllFlags({ context: { kind: 'user', key: 'u_999', plan: 'pro' },})Exposures are suppressed by default here, and the returned object is stable between changes. Before the SDK is ready it returns {} and logs one warning saying why, rather than looking like a project with no flags.
Options
import type { AvsbClientOptions } from '@avsbhq/browser'const options: AvsbClientOptions = { sdkKey: 'sdk_production_ttqm0eaj4vth1krcb2xn', pollingInterval: 60_000, // default 60s, clamped to a 5s minimum autoRefresh: true, // default true cdnHost: 'https://cdn.avsb.cloud', // default autoInit: true, // default true: start loading from the constructor initTimeout: 10_000, // default, per datafile request cache: true, // default true: localStorage datafile cache cacheMaxAgeMs: 604_800_000, // default 7 days pauseWhenHidden: true, // default true refetchOnFocus: true, // default true maxPendingEvents: 100, // events held before the client is ready logLevel: 'warn', // 'silent' | 'debug' | 'info' | 'warn' | 'error' streaming: false, // default false in the browser}The datafile is cached in localStorage and served on the next visit while a refresh runs behind it, so second loads are instant and flags keep working offline. Refreshes are conditional (an unchanged datafile costs a 304 with no body), and only network responses are cached: a bootstrap datafile you pass in never is.
The default logger writes to the console at warn level in development and is silent elsewhere. Development is detected from globalThis.__AVSB_DEV__, then NODE_ENV, then the host.
Bootstrap and server rendering
Without a bootstrap, the client fetches the datafile on mount and reads return defaults until it lands. Pre-fetch it on the server and hand it over to remove that window:
// Serverimport { fetchDatafile } from '@avsbhq/browser/server'import type { FlagDatafile } from '@avsbhq/browser'const serverDatafile: FlagDatafile | null = await fetchDatafile( 'sdk_production_ttqm0eaj4vth1krcb2xn', { timeout: 2000 },)// null when the fetch failed, in which case the browser fetches it itself.// Clientimport { AvsbClient } from '@avsbhq/browser'import type { FlagDatafile } from '@avsbhq/browser'declare const datafileFromServer: FlagDatafileconst bootstrapped = new AvsbClient({ sdkKey: 'sdk_production_ttqm0eaj4vth1krcb2xn', context: { kind: 'user', key: 'u_123' }, bootstrap: datafileFromServer,})// isReady() is already true; onReady() resolves with source: 'bootstrap'.Bootstrapping guarantees that the server render and the first client render produce the same variation, which is what prevents a hydration mismatch. On Next.js, AvsbRoot from @avsbhq/next does all of this for you.
Shutting down
await client.flush() // drain queued eventsawait client.close() // flush, stop polling, disconnect streamingclose() flushes internally, so calling both is safe but redundant. track() after close() is dropped with one warning rather than silently.
Connection status
The SDK reports liveness to the platform after a successful datafile fetch, using the endpoint published in the datafile itself. Nothing needs configuring, and a failed report never affects flag delivery.
The environments page and the flag detail page show what has been heard:
- Connected: a report arrived within the last 5 minutes.
- Stale: the last report is older than 5 minutes.
- Not connected: nothing has ever reported in.
A long-lived browser tab that stops polling (a hidden tab, or autoRefresh: false) will drift to Stale without anything being wrong.
What's next
- Your first flag
- Multi-context identity
- Sticky bucketing
- Credentials: all four A vs B credentials and which one to reach for.