Experiments & Feature Flags
The @54vie/analytics package provides a complete experimentation platform including A/B testing, multivariate testing, and feature flags. This guide covers the experiment APIs, feature flags, and conversion tracking.
Installation
npm install @54vie/analytics
# or
yarn add @54vie/analytics
TypeScript Interfaces
Experiment Types
/**
* Configuration for an experiment
*/
interface Experiment {
id: string;
name: string;
description?: string;
status: ExperimentStatus;
type: ExperimentType;
variants: Variant[];
targetingRules?: TargetingRule[];
trafficAllocation: number;
startDate?: string;
endDate?: string;
primaryMetric?: string;
secondaryMetrics?: string[];
createdAt: string;
updatedAt: string;
}
enum ExperimentStatus {
DRAFT = 'draft',
RUNNING = 'running',
PAUSED = 'paused',
COMPLETED = 'completed',
ARCHIVED = 'archived',
}
enum ExperimentType {
AB_TEST = 'ab_test',
MULTIVARIATE = 'multivariate',
FEATURE_FLAG = 'feature_flag',
ROLLOUT = 'rollout',
}
interface Variant {
id: string;
name: string;
description?: string;
weight: number;
payload?: Record<string, unknown>;
isControl?: boolean;
}
interface TargetingRule {
id: string;
attribute: string;
operator: TargetingOperator;
value: string | number | boolean | string[];
}
enum TargetingOperator {
EQUALS = 'equals',
NOT_EQUALS = 'not_equals',
CONTAINS = 'contains',
NOT_CONTAINS = 'not_contains',
STARTS_WITH = 'starts_with',
ENDS_WITH = 'ends_with',
GREATER_THAN = 'greater_than',
LESS_THAN = 'less_than',
IN = 'in',
NOT_IN = 'not_in',
REGEX = 'regex',
IS_SET = 'is_set',
IS_NOT_SET = 'is_not_set',
}
Feature Flag Types
/**
* Configuration for a feature flag
*/
interface FeatureFlag {
id: string;
key: string;
name: string;
description?: string;
type: FeatureFlagType;
defaultValue: FlagValue;
enabled: boolean;
targetingRules?: TargetingRule[];
rolloutPercentage?: number;
variants?: FlagVariant[];
createdAt: string;
updatedAt: string;
}
enum FeatureFlagType {
BOOLEAN = 'boolean',
STRING = 'string',
NUMBER = 'number',
JSON = 'json',
}
type FlagValue = boolean | string | number | Record<string, unknown>;
interface FlagVariant {
value: FlagValue;
weight: number;
name?: string;
}
interface FeatureFlagEvaluation {
key: string;
value: FlagValue;
reason: EvaluationReason;
ruleId?: string;
variantKey?: string;
}
enum EvaluationReason {
DEFAULT = 'default',
TARGETING_MATCH = 'targeting_match',
ROLLOUT = 'rollout',
OVERRIDE = 'override',
ERROR = 'error',
}
Hook Return Types
/**
* Return type for useExperiment hook
*/
interface UseExperimentReturn<T = string> {
variant: T;
isLoading: boolean;
isActive: boolean;
error: Error | null;
trackConversion: (conversionName?: string, properties?: EventProperties) => void;
trackExposure: () => void;
metadata: ExperimentMetadata | null;
}
interface ExperimentMetadata {
experimentId: string;
experimentName: string;
variantId: string;
variantName: string;
startedAt: string;
endsAt?: string;
}
/**
* Return type for useFeatureFlag hook
*/
interface UseFeatureFlagReturn<T = boolean> {
value: T;
isLoading: boolean;
isEnabled: boolean;
error: Error | null;
evaluation: FeatureFlagEvaluation | null;
}
/**
* Return type for useFeatureFlags hook (batch)
*/
interface UseFeatureFlagsReturn {
flags: Record<string, FlagValue>;
isLoading: boolean;
error: Error | null;
getFlag: <T = boolean>(key: string, defaultValue?: T) => T;
isEnabled: (key: string) => boolean;
}
useExperiment Hook
The useExperiment hook provides access to A/B test variants and handles experiment tracking.
Import
import { useExperiment } from '@54vie/analytics';
Basic Usage
import { useExperiment } from '@54vie/analytics';
function PricingPage() {
const { variant, isLoading, trackConversion } = useExperiment<'control' | 'new_layout'>(
'pricing_page_experiment',
{ defaultVariant: 'control' }
);
if (isLoading) {
return <LoadingSpinner />;
}
const handlePurchase = (planId: string) => {
trackConversion('purchase', { planId });
};
if (variant === 'new_layout') {
return <NewPricingLayout onPurchase={handlePurchase} />;
}
return <ControlPricingLayout onPurchase={handlePurchase} />;
}
Hook Options
interface UseExperimentOptions<T = string> {
/**
* Default variant if experiment not found or user not in experiment
*/
defaultVariant?: T;
/**
* Automatically track exposure when variant is accessed
* @default true
*/
autoTrackExposure?: boolean;
/**
* Skip this experiment (user always gets default variant)
*/
skip?: boolean;
/**
* Custom attributes for targeting
*/
attributes?: Record<string, unknown>;
/**
* Override the variant for testing purposes
*/
overrideVariant?: T;
}
Advanced Usage
import { useExperiment } from '@54vie/analytics';
type CheckoutVariant = 'single_page' | 'multi_step' | 'express';
function CheckoutPage({ user }) {
const {
variant,
isLoading,
isActive,
trackConversion,
trackExposure,
metadata,
error,
} = useExperiment<CheckoutVariant>('checkout_flow_experiment', {
defaultVariant: 'multi_step',
autoTrackExposure: false, // Manual exposure tracking
skip: user.isEmployee, // Skip for employees
attributes: {
userTier: user.tier,
country: user.country,
deviceType: getDeviceType(),
},
});
// Manual exposure tracking when component becomes visible
useEffect(() => {
if (!isLoading && isActive) {
trackExposure();
}
}, [isLoading, isActive]);
// Track multiple conversion events
const handleCheckoutStart = () => {
trackConversion('checkout_started');
};
const handleStepComplete = (step: number) => {
trackConversion('step_completed', { step });
};
const handlePurchase = (order: Order) => {
trackConversion('purchase', {
orderId: order.id,
revenue: order.total,
currency: order.currency,
});
};
if (error) {
console.error('Experiment error:', error);
// Fall back to default experience
}
const checkoutComponents: Record<CheckoutVariant, React.FC> = {
single_page: SinglePageCheckout,
multi_step: MultiStepCheckout,
express: ExpressCheckout,
};
const CheckoutComponent = checkoutComponents[variant];
return (
<CheckoutComponent
onStart={handleCheckoutStart}
onStepComplete={handleStepComplete}
onPurchase={handlePurchase}
/>
);
}
With Variant Payloads
import { useExperiment } from '@54vie/analytics';
interface ButtonVariantPayload {
color: string;
text: string;
size: 'small' | 'medium' | 'large';
}
function CTAButton() {
const { variant, metadata } = useExperiment<'control' | 'variant_a' | 'variant_b'>(
'cta_button_test'
);
// Access variant payload from experiment configuration
const payload = metadata?.payload as ButtonVariantPayload | undefined;
return (
<button
style={{ backgroundColor: payload?.color ?? 'blue' }}
className={`btn-${payload?.size ?? 'medium'}`}
>
{payload?.text ?? 'Sign Up'}
</button>
);
}
useFeatureFlag Hook
The useFeatureFlag hook provides access to individual feature flags.
Import
import { useFeatureFlag } from '@54vie/analytics';
Basic Usage
import { useFeatureFlag } from '@54vie/analytics';
function Dashboard() {
const { value: showNewWidget, isLoading } = useFeatureFlag('new_dashboard_widget');
if (isLoading) {
return <LoadingSpinner />;
}
return (
<div>
<ExistingWidgets />
{showNewWidget && <NewWidget />}
</div>
);
}
Hook Options
interface UseFeatureFlagOptions<T = boolean> {
/**
* Default value if flag not found or evaluation fails
*/
defaultValue?: T;
/**
* Custom attributes for targeting
*/
attributes?: Record<string, unknown>;
/**
* Track flag evaluation as an event
* @default false
*/
trackEvaluation?: boolean;
/**
* Override the flag value for testing
*/
overrideValue?: T;
}
Typed Feature Flags
import { useFeatureFlag } from '@54vie/analytics';
// Boolean flag
function NewFeatureComponent() {
const { value: isEnabled } = useFeatureFlag<boolean>('new_feature_enabled', {
defaultValue: false,
});
if (!isEnabled) return null;
return <NewFeature />;
}
// String flag
function ThemeComponent() {
const { value: theme } = useFeatureFlag<'light' | 'dark' | 'system'>('default_theme', {
defaultValue: 'system',
});
return <ThemeProvider theme={theme}>{/* ... */}</ThemeProvider>;
}
// Number flag
function RateLimitedComponent() {
const { value: maxRequests } = useFeatureFlag<number>('api_rate_limit', {
defaultValue: 100,
});
return <RateLimiter maxRequests={maxRequests}>{/* ... */}</RateLimiter>;
}
// JSON flag
interface FeatureConfig {
maxItems: number;
showBanner: boolean;
bannerText: string;
}
function ConfigurableFeature() {
const { value: config } = useFeatureFlag<FeatureConfig>('feature_config', {
defaultValue: {
maxItems: 10,
showBanner: false,
bannerText: '',
},
});
return (
<div>
{config.showBanner && <Banner text={config.bannerText} />}
<ItemList maxItems={config.maxItems} />
</div>
);
}
With Targeting Attributes
import { useFeatureFlag } from '@54vie/analytics';
function PremiumFeature({ user }) {
const { value: hasAccess, evaluation } = useFeatureFlag('premium_feature', {
defaultValue: false,
attributes: {
userId: user.id,
plan: user.subscription.plan,
country: user.country,
accountAge: calculateAccountAge(user.createdAt),
},
trackEvaluation: true,
});
// Log evaluation reason for debugging
useEffect(() => {
if (evaluation) {
console.log(`Flag evaluated: ${evaluation.reason}`, evaluation);
}
}, [evaluation]);
if (!hasAccess) {
return <UpgradePrompt />;
}
return <PremiumFeatureContent />;
}
useFeatureFlags Hook (Batch)
For accessing multiple feature flags efficiently.
Import
import { useFeatureFlags } from '@54vie/analytics';
Usage
import { useFeatureFlags } from '@54vie/analytics';
function App() {
const { flags, isLoading, getFlag, isEnabled } = useFeatureFlags([
'new_navigation',
'dark_mode',
'beta_features',
'api_v2',
]);
if (isLoading) {
return <LoadingSpinner />;
}
return (
<div>
{isEnabled('new_navigation') ? <NewNavigation /> : <LegacyNavigation />}
<ThemeProvider darkMode={getFlag('dark_mode', false)}>
<MainContent />
</ThemeProvider>
{isEnabled('beta_features') && <BetaFeatures />}
</div>
);
}
Conversion Tracking
Basic Conversion Tracking
import { useExperiment } from '@54vie/analytics';
function SignupForm() {
const { variant, trackConversion } = useExperiment('signup_flow');
const handleSignup = async (formData: FormData) => {
const user = await createUser(formData);
// Track primary conversion
trackConversion('signup_completed', {
userId: user.id,
signupMethod: formData.method,
});
};
return <form onSubmit={handleSignup}>{/* form fields */}</form>;
}
Multiple Conversion Points
import { useExperiment } from '@54vie/analytics';
function OnboardingFlow() {
const { trackConversion } = useExperiment('onboarding_experiment');
// Track funnel progression
const trackStep = (step: number, data?: Record<string, unknown>) => {
trackConversion(`onboarding_step_${step}`, data);
};
// Track completion
const trackComplete = () => {
trackConversion('onboarding_completed', {
totalTime: calculateElapsedTime(),
});
};
// Track drop-off
const trackDropoff = (step: number, reason?: string) => {
trackConversion('onboarding_dropoff', {
step,
reason,
});
};
return <OnboardingUI onStep={trackStep} onComplete={trackComplete} />;
}
Revenue Tracking
import { useExperiment } from '@54vie/analytics';
function CheckoutComplete({ order }) {
const { trackConversion } = useExperiment('checkout_experiment');
useEffect(() => {
// Track purchase with revenue
trackConversion('purchase', {
revenue: order.total,
currency: order.currency,
orderId: order.id,
itemCount: order.items.length,
});
// Track per-item revenue
order.items.forEach((item) => {
trackConversion('item_purchased', {
itemId: item.id,
itemName: item.name,
price: item.price,
quantity: item.quantity,
category: item.category,
});
});
}, [order.id]);
return <OrderConfirmation order={order} />;
}
useExperiments Hook (Multiple Experiments)
For components participating in multiple experiments.
Import
import { useExperiments } from '@54vie/analytics';
Usage
import { useExperiments } from '@54vie/analytics';
function ProductPage({ product }) {
const experiments = useExperiments({
layout: 'product_page_layout',
pricing: 'pricing_display_test',
recommendations: 'recommendation_algorithm',
});
if (experiments.isLoading) {
return <LoadingSpinner />;
}
const layoutVariant = experiments.getVariant('layout', 'control');
const pricingVariant = experiments.getVariant('pricing', 'standard');
const recsVariant = experiments.getVariant('recommendations', 'collaborative');
const handlePurchase = () => {
// Track conversion for all active experiments
experiments.trackConversion('purchase', {
productId: product.id,
price: product.price,
});
};
return (
<ProductLayout variant={layoutVariant}>
<ProductDetails product={product} />
<PriceDisplay product={product} variant={pricingVariant} />
<Recommendations variant={recsVariant} />
<BuyButton onClick={handlePurchase} />
</ProductLayout>
);
}
Server-Side Rendering
Next.js App Router
// app/page.tsx
import { ExperimentProvider, getExperimentVariant } from '@54vie/analytics/server';
export default async function Page() {
// Fetch variant on server
const variant = await getExperimentVariant('homepage_experiment', {
defaultVariant: 'control',
});
return (
<ExperimentProvider
experimentKey="homepage_experiment"
initialVariant={variant}
>
<HomePageContent />
</ExperimentProvider>
);
}
// Client component
'use client';
import { useExperiment } from '@54vie/analytics';
function HomePageContent() {
const { variant, trackConversion } = useExperiment('homepage_experiment');
// variant is hydrated from server, no loading state
return variant === 'new_hero' ? <NewHero /> : <ControlHero />;
}
Feature Flags with SSR
// app/layout.tsx
import { FeatureFlagProvider, evaluateFlags } from '@54vie/analytics/server';
export default async function RootLayout({ children }) {
const flags = await evaluateFlags(['new_navigation', 'dark_mode']);
return (
<html>
<body>
<FeatureFlagProvider initialFlags={flags}>
{children}
</FeatureFlagProvider>
</body>
</html>
);
}
Testing & Development
Override Variants in Development
import { AnalyticsProvider } from '@54vie/analytics';
function App() {
return (
<AnalyticsProvider
apiKey={process.env.NEXT_PUBLIC_ANALYTICS_KEY!}
projectId={process.env.NEXT_PUBLIC_PROJECT_ID!}
config={{
experiments: {
// Force specific variants in development
overrides: process.env.NODE_ENV === 'development' ? {
'checkout_experiment': 'express',
'pricing_test': 'variant_b',
} : undefined,
},
}}
>
<YourApp />
</AnalyticsProvider>
);
}
URL-Based Overrides
// Enable via URL: ?experiment_checkout=express
import { useExperiment } from '@54vie/analytics';
function Checkout() {
const searchParams = new URLSearchParams(window.location.search);
const urlOverride = searchParams.get('experiment_checkout');
const { variant } = useExperiment('checkout_experiment', {
overrideVariant: urlOverride as any,
});
// ...
}
Mock Experiments in Tests
import { render } from '@testing-library/react';
import { MockAnalyticsProvider } from '@54vie/analytics/testing';
describe('PricingPage', () => {
it('renders control variant', () => {
render(
<MockAnalyticsProvider
experiments={{
pricing_test: { variant: 'control', isActive: true },
}}
>
<PricingPage />
</MockAnalyticsProvider>
);
expect(screen.getByText('Standard Pricing')).toBeInTheDocument();
});
it('renders new pricing variant', () => {
render(
<MockAnalyticsProvider
experiments={{
pricing_test: { variant: 'new_pricing', isActive: true },
}}
>
<PricingPage />
</MockAnalyticsProvider>
);
expect(screen.getByText('New Pricing Layout')).toBeInTheDocument();
});
});
Best Practices
Experiment Design
// Good - clear, measurable experiment
const experiment = {
name: 'checkout_simplification',
hypothesis: 'Reducing checkout steps will increase conversion rate',
primaryMetric: 'checkout_completed',
secondaryMetrics: ['cart_abandonment', 'average_order_value'],
minimumSampleSize: 10000,
duration: '14 days',
};
// Define clear variants
type CheckoutVariant = 'control' | 'simplified' | 'express';
Gradual Rollouts
import { useFeatureFlag } from '@54vie/analytics';
function NewFeature() {
const { value: rolloutPercentage } = useFeatureFlag<number>('new_feature_rollout', {
defaultValue: 0,
});
// Feature is gradually rolled out via dashboard
// 0% -> 5% -> 25% -> 50% -> 100%
if (rolloutPercentage === 0) {
return null;
}
return <FeatureContent />;
}
Cleanup After Experiments
// Before experiment ends
function ComponentWithExperiment() {
const { variant } = useExperiment('button_color_test');
return variant === 'green' ? <GreenButton /> : <BlueButton />;
}
// After experiment concludes with 'green' as winner
function ComponentAfterExperiment() {
// Remove experiment code, ship winning variant
return <GreenButton />;
}
Avoiding Flickering
import { useExperiment } from '@54vie/analytics';
function HeroSection() {
const { variant, isLoading } = useExperiment('hero_test');
// Show skeleton while loading to prevent layout shift
if (isLoading) {
return <HeroSkeleton />;
}
return variant === 'video_hero' ? <VideoHero /> : <ImageHero />;
}