From PostHog
PostHog is a product analytics platform that ships feature flags as one of several built-in tools. If you are migrating only your feature flag and A/B test layer (keeping PostHog for analytics), A vs B slots in alongside it. If you are replacing PostHog entirely, A vs B covers feature flags and experiment analysis; you will need a separate product analytics tool for session recording and funnels. PostHog's feature flag API surface is compact, so the migration is small: swap three methods and add an event-context object.
Concept mapping
| PostHog Feature Flags | A vs B |
|---|---|
| Feature flag | Feature flag (a hyphen in a key becomes an underscore: new-checkout becomes new_checkout) |
posthog.getFeatureFlag(key) | client.getFlag(key, null).value |
posthog.isFeatureEnabled(key) | client.getBoolFlag(key, false).isEnabled() |
posthog.getFeatureFlagPayload(key) | client.getJsonFlag<T>(key, null).value |
| String / multivariate flag | String flag (getStringFlag) |
posthog.identify(userId, properties) | client.identify({ kind: 'user', key: userId, ...properties }) |
posthog.capture(eventName, properties) | client.track(eventKey, { properties }) |
posthog.reloadFeatureFlags() | client.refresh() (manual datafile refresh) |
| PostHog distinct ID | context.key |
| Person properties | Top-level attributes on EvalContext |
| No holdout concept | FlagDatafileHoldout (available on all plans) |
| No multi-context | MultiContext (new capability gained on migration) |
posthog.reset() | client.identify({ kind: 'user', key: 'anon_new' }) |
Client construction
PostHog initializes via a global posthog.init call with the API key and host. A vs B uses an explicit client instance with an SDK key.
PostHog, Browser (posthog-js):
import posthog from 'posthog-js';posthog.init('phc_YOUR_KEY', { api_host: 'https://app.posthog.com',});A vs B, Browser:
import { AvsbClient } from '@avsbhq/browser';const client = new AvsbClient({ sdkKey: 'sdk_production_xxxxxxxxxxxxxxxx', context: { kind: 'user', key: posthog.get_distinct_id() },});await client.onReady();PostHog, Node (posthog-node):
import { PostHog } from 'posthog-node';const posthog = new PostHog('phc_YOUR_KEY', { host: 'https://app.posthog.com' });A vs B, Node:
import { AvsbServer } from '@avsbhq/node';const server = new AvsbServer({ sdkKey: process.env.AVSB_SDK_KEY! });const result = await server.onReady();PostHog's feature flags work with a project API key that also drives analytics. A vs B uses a separate per-environment SDK key that is safe to expose in browser code. Find it on the Environments page in your feature flag project.
Identity model
PostHog uses posthog.identify(distinctId, properties) to associate a user and their properties with the PostHog distinct ID. A vs B maps this directly to client.identify with a context object.
PostHog:
// Identify after loginposthog.identify('user_123', { email: 'user@example.com', plan: 'pro', company: 'Acme',});// Partial update: re-call identify with all propertiesA vs B:
// Full context replacement (equivalent to PostHog identify)client.identify({ kind: 'user', key: 'user_123', email: 'user@example.com', plan: 'pro', company: 'Acme',});// Partial attribute patch: no need to repeat unchanged fieldsclient.updateAttributes({ plan: 'enterprise' });// Stitch anonymous pre-login identity to the identified user. Synchronous:// it records the moment, it does not move past decisions.client.alias( { kind: 'user', key: posthog.get_distinct_id() }, // anonymous id { kind: 'user', key: 'user_123' } // identified id);Flag evaluation
PostHog provides three flag evaluation methods. A vs B unifies them under typed evaluators that always return a Flag<T> envelope, so you get evaluation metadata for free without a separate "details" call.
PostHog, Browser:
// Boolean checkconst showBanner = posthog.isFeatureEnabled('show_banner');// Multivariate / string variantconst theme = posthog.getFeatureFlag('homepage_theme'); // returns string | boolean | undefined// JSON payloadconst config = posthog.getFeatureFlagPayload('pricing_config'); // returns JsonType | nullA vs B, Browser:
// Boolean flagconst showBanner = client.getBoolFlag('show_banner', false).value;// Or use isEnabled(): true only when a real decision was made (a rule,// holdout, bandit, or override matched, not just the default) and the// value is truthy.const enabled = client.getBoolFlag('show_banner', false).isEnabled();// String / multivariate flagconst theme = client.getStringFlag('homepage_theme', 'default').value;// JSON payload. Unlike PostHog's `JsonType | null`, the default you pass is// the type you get back, so there is no null branch at the call site.interface PricingConfig { basePrice: number; currency: string }const config = client.getJsonFlag<PricingConfig>('pricing_config', { basePrice: 99, currency: 'USD',}).value;// Full envelope (equivalent to fetching metadata)const flag = client.getBoolFlag('show_banner', false);flag.variationKey // 'test' | 'control' | nullflag.source // 'rule' | 'default' | 'holdout' | ...flag.reasons // string[]PostHog, Node:
// Requires a distinct ID per call (no client-level binding)const flagValue = await posthog.getFeatureFlag('show_banner', 'user_123');const isEnabled = await posthog.isFeatureEnabled('show_banner', 'user_123');A vs B, Node:
const flag = server.forUser({ kind: 'user', key: 'user_123' }) .getBoolFlag('show_banner', false);Tracking events
PostHog uses posthog.capture for all analytics events. In A vs B, client.track sends conversion events to the experiment analytics pipeline. If you are keeping PostHog for analytics, you can call both; they operate independently.
PostHog:
posthog.capture('purchase_completed', { revenue: 49.99, orderId: 'ord_99', plan: 'pro',});A vs B:
// Sends to A vs B experiment analytics only. `revenue` is money in major// units (49.99, not 4999). Conversion events do not store properties// (only exposure events do), so orderId and plan have no A vs B// equivalent on this call.client.track('purchase_completed', { revenue: 49.99 });// If keeping PostHog for analytics, also call:// posthog.capture('purchase_completed', { revenue: 49.99, ... });Multi-context
PostHog targets feature flags on a single user identity. A vs B adds multi-context targeting, a new capability you gain on migration. You can target and bucket on organization, device, or any other entity kind alongside the user context.
A vs B, multi-context (new capability):
import type { MultiContext } from '@avsbhq/core';const ctx: MultiContext = { kind: 'multi', user: { kind: 'user', key: 'user_123', plan: 'pro' }, organization: { kind: 'organization', key: 'org_42', tier: 'enterprise' },};server.forUser(ctx).getBoolFlag('enterprise_feature', false);Streaming updates
PostHog reloads feature flags via posthog.reloadFeatureFlags() or on an internal timer. A vs B polls automatically at a configurable interval and emits events when the datafile changes.
PostHog:
posthog.onFeatureFlags((flags: string[]) => { // Flags loaded or reloaded});A vs B:
client.on('configUpdate', ({ reason }) => { // Datafile refreshed; reason: 'poll' | 'stream' | 'manual'});client.on('flagChange', ({ flagKey, previousValue, newValue }) => { // A specific flag value changed});// Manual refresh (equivalent to reloadFeatureFlags)await client.refresh();Bootstrap / SSR
PostHog's server-side Node SDK evaluates flags per request without a bootstrap step. A vs B supports a full SSR bootstrap pattern that eliminates client-side loading state entirely.
PostHog, Next.js per-request eval:
// Server componentconst flagValue = await posthog.isFeatureEnabled('show_banner', 'user_123');A vs B, Next.js bootstrap:
// Server component (app/layout.tsx)import { AvsbProvider } from '@avsbhq/react';import { fetchDatafile } from '@avsbhq/browser/server';const datafile = await fetchDatafile(process.env.AVSB_SDK_KEY!);// Client provider: flags evaluate synchronously on first render<AvsbProvider sdkKey="..." context={ctx} bootstrap={datafile ?? undefined}> <YourApp /></AvsbProvider>Holdouts
PostHog feature flags do not have a holdout concept. A vs B includes holdouts on all plans. Create a holdout in the dashboard and associate flags with it to maintain a clean control group across multiple concurrent experiments.
Cleanup
PostHog, Node:
await posthog.shutdown();A vs B:
await server.close(); // flushes buffered events, stops pollingTesting
PostHog, test override:
// PostHog test mode: use overrideFeatureFlagposthog.featureFlags.override({ show_banner: true });A vs B, mock client:
import { createMockClient } from '@avsbhq/test';const mock = createMockClient({ flags: { show_banner: true, homepage_theme: 'blue', },});Cutover checklist
Decide scope
Determine whether you are replacing only the feature flag layer (keeping PostHog for analytics) or replacing PostHog entirely. If keeping PostHog for analytics, keep posthog.capture calls in place and add client.track alongside them for experiment conversions.
Remove or reduce PostHog packages
If fully replacing: uninstall posthog-js and posthog-node. If keeping analytics: leave the packages but remove feature flag calls.
Install A vs B packages
Install @avsbhq/browser and/or @avsbhq/node and @avsbhq/react as needed.
Migrate identify calls
Replace posthog.identify(distinctId, properties) with client.identify({ kind: 'user', key: distinctId, ...properties }).
Replace flag evaluation calls
Replace posthog.isFeatureEnabled(key) with client.getBoolFlag(key, false).isEnabled(). Replace posthog.getFeatureFlag(key) with client.getStringFlag(key, null).value. Replace posthog.getFeatureFlagPayload(key) with client.getJsonFlag<T>(key, null).value.
Add conversion tracking
Add client.track(eventKey, { value, properties }) at each conversion point you want to measure in A/B test results.
Update tests
Replace posthog.featureFlags.override with createMockClient from @avsbhq/test.
Verify and deploy
Run npm run build and npx tsc --noEmit. Confirm flag evaluations and conversion events appear in the A vs B dashboard before promoting to production.