FlagSignals for Next.js
A comprehensive guide to integrating feature flags into your Next.js application using FlagSignals.
Evaluate flags on the server for zero layout shift and instant rendering.
Full TypeScript support with strongly-typed flag values.
Works in Edge Runtime, Middleware, and serverless functions.
Quickstart
Get up and running with FlagSignals in under 5 minutes.
1. Get your API key
Sign up for FlagSignals, create a project, and copy your environment API key from the Environments page.
2. Add to environment variables
FLAGSIGNALS_API_KEY=your_api_key_here
FLAGSIGNALS_URL=https://your-project.supabase.co/functions/v1/evaluate3. Create a flags utility
export async function getFlags(flagKeys?: string[]) {
const response = await fetch(process.env.FLAGSIGNALS_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.FLAGSIGNALS_API_KEY!,
},
body: JSON.stringify({ flags: flagKeys }),
next: { revalidate: 60 }, // Cache for 60 seconds
});
if (!response.ok) {
throw new Error('Failed to fetch flags');
}
return response.json();
}4. Use in your components
import { getFlags } from '@/lib/flags';
export default async function Page() {
const { flags } = await getFlags(['new_feature']);
return (
<div>
{flags.new_feature?.value && (
<NewFeatureComponent />
)}
</div>
);
}Installation
FlagSignals works with plain fetch - no SDK required. However, we recommend creating a utility module for better type safety and reusability.
Complete Setup
Create these files in your Next.js project:
// Flag evaluation types
export interface FlagValue {
value: unknown;
reason: 'default' | 'disabled' | 'targeted';
}
export interface FlagsResponse {
flags: Record<string, FlagValue>;
evaluated_at: string;
}
export interface EvaluateOptions {
flags?: string[];
context?: {
userId?: string;
email?: string;
[key: string]: unknown;
};
}
class FlagsClient {
private baseUrl: string;
private apiKey: string;
constructor() {
this.baseUrl = process.env.FLAGSIGNALS_URL!;
this.apiKey = process.env.FLAGSIGNALS_API_KEY!;
if (!this.baseUrl || !this.apiKey) {
throw new Error('Missing FLAGSIGNALS_URL or FLAGSIGNALS_API_KEY');
}
}
async evaluate(options: EvaluateOptions = {}): Promise<FlagsResponse> {
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
},
body: JSON.stringify(options),
next: { revalidate: 60 },
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || 'Failed to evaluate flags');
}
return response.json();
}
async getFlag<T = boolean>(
key: string,
defaultValue: T,
context?: EvaluateOptions['context']
): Promise<T> {
try {
const { flags } = await this.evaluate({ flags: [key], context });
return (flags[key]?.value as T) ?? defaultValue;
} catch {
return defaultValue;
}
}
}
// Singleton instance
let client: FlagsClient | null = null;
export function getFlags() {
if (!client) {
client = new FlagsClient();
}
return client;
}Server Components
Server Components are the recommended way to use feature flags in Next.js. Flags are evaluated on the server before the page is sent to the client, resulting in zero layout shift.
Basic Usage
import { getFlags } from '@/lib/flags';
export default async function DashboardPage() {
const flags = getFlags();
const showNewDashboard = await flags.getFlag('new_dashboard', false);
if (showNewDashboard) {
return <NewDashboard />;
}
return <LegacyDashboard />;
}Multiple Flags
Fetch multiple flags in a single request for better performance:
import { getFlags } from '@/lib/flags';
export default async function HomePage() {
const flags = getFlags();
const { flags: featureFlags } = await flags.evaluate({
flags: ['hero_variant', 'show_testimonials', 'enable_chat'],
});
return (
<main>
{/* A/B test hero section */}
{featureFlags.hero_variant?.value === 'minimal' ? (
<MinimalHero />
) : (
<DefaultHero />
)}
{/* Conditionally show testimonials */}
{featureFlags.show_testimonials?.value && (
<TestimonialsSection />
)}
{/* Feature flag for chat widget */}
{featureFlags.enable_chat?.value && (
<ChatWidget />
)}
</main>
);
}With User Context
Pass user information for targeted flag evaluation:
import { getFlags } from '@/lib/flags';
import { auth } from '@/lib/auth';
export default async function SettingsPage() {
const session = await auth();
const flags = getFlags();
const { flags: userFlags } = await flags.evaluate({
flags: ['beta_features', 'advanced_settings'],
context: {
userId: session?.user?.id,
email: session?.user?.email,
plan: session?.user?.plan, // Custom attribute
},
});
return (
<div>
<h1>Settings</h1>
<BasicSettings />
{userFlags.advanced_settings?.value && (
<AdvancedSettings />
)}
{userFlags.beta_features?.value && (
<BetaFeaturesPanel />
)}
</div>
);
}In Layouts
Use flags in layouts to control app-wide features:
import { getFlags } from '@/lib/flags';
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
const flags = getFlags();
const showBanner = await flags.getFlag('maintenance_banner', false);
const newNav = await flags.getFlag('new_navigation', false);
return (
<html lang="en">
<body>
{showBanner && (
<MaintenanceBanner />
)}
{newNav ? <NewNavigation /> : <Navigation />}
{children}
<Footer />
</body>
</html>
);
}Client Components
For client-side flag evaluation, pass flags from a Server Component or use a React Context provider.
Passing Flags as Props
The simplest approach is to fetch flags on the server and pass them to client components:
import { getFlags } from '@/lib/flags';
import { InteractiveFeature } from '@/components/interactive-feature';
export default async function Page() {
const flags = getFlags();
const { flags: featureFlags } = await flags.evaluate({
flags: ['animation_style', 'show_confetti'],
});
return (
<InteractiveFeature
animationStyle={featureFlags.animation_style?.value as string}
showConfetti={featureFlags.show_confetti?.value as boolean}
/>
);
}'use client';
interface Props {
animationStyle: string;
showConfetti: boolean;
}
export function InteractiveFeature({ animationStyle, showConfetti }: Props) {
const handleClick = () => {
if (showConfetti) {
triggerConfetti();
}
};
return (
<button
onClick={handleClick}
className={`animate-${animationStyle}`}
>
Click me!
</button>
);
}Client-Side Fetching
For dynamic flag updates without page refresh, create a client-side hook:
'use client';
import { useState, useEffect } from 'react';
interface FlagValue {
value: unknown;
reason: string;
}
export function useFlags(flagKeys: string[]) {
const [flags, setFlags] = useState<Record<string, FlagValue>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
async function fetchFlags() {
try {
// Call your own API route that proxies to FlagSignals
const response = await fetch('/api/flags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ flags: flagKeys }),
});
if (!response.ok) throw new Error('Failed to fetch flags');
const data = await response.json();
setFlags(data.flags);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}
fetchFlags();
}, [flagKeys.join(',')]);
return { flags, loading, error };
}Create an API route to proxy requests (keeps your API key secure):
import { NextResponse } from 'next/server';
export async function POST(request: Request) {
const body = await request.json();
const response = await fetch(process.env.FLAGSIGNALS_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.FLAGSIGNALS_API_KEY!,
},
body: JSON.stringify(body),
});
const data = await response.json();
return NextResponse.json(data);
}Middleware
Use feature flags in Next.js middleware for A/B testing, redirects, and request-level feature gating.
A/B Testing with Redirects
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
async function getFlag(key: string): Promise<boolean> {
try {
const response = await fetch(process.env.FLAGSIGNALS_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.FLAGSIGNALS_API_KEY!,
},
body: JSON.stringify({ flags: [key] }),
});
const data = await response.json();
return data.flags[key]?.value ?? false;
} catch {
return false;
}
}
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// A/B test: Redirect to new pricing page
if (pathname === '/pricing') {
const useNewPricing = await getFlag('new_pricing_page');
if (useNewPricing) {
return NextResponse.rewrite(new URL('/pricing-v2', request.url));
}
}
// Feature gate: Block access to beta features
if (pathname.startsWith('/beta')) {
const betaEnabled = await getFlag('beta_access');
if (!betaEnabled) {
return NextResponse.redirect(new URL('/coming-soon', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/pricing', '/beta/:path*'],
};User-Based Targeting
Target flags based on user cookies or headers:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
async function evaluateFlags(context: Record<string, unknown>) {
const response = await fetch(process.env.FLAGSIGNALS_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.FLAGSIGNALS_API_KEY!,
},
body: JSON.stringify({
flags: ['dark_mode_default', 'new_onboarding'],
context,
}),
});
return response.json();
}
export async function middleware(request: NextRequest) {
// Get user ID from cookie
const userId = request.cookies.get('user_id')?.value;
const userPlan = request.cookies.get('user_plan')?.value;
const { flags } = await evaluateFlags({
userId,
plan: userPlan,
country: request.geo?.country,
});
// Set response headers based on flags
const response = NextResponse.next();
if (flags.dark_mode_default?.value) {
response.cookies.set('theme', 'dark');
}
return response;
}Caching in Middleware
Middleware runs on every request. Use caching to improve performance:
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Simple in-memory cache (resets on cold start)
const cache = new Map<string, { value: unknown; expires: number }>();
async function getCachedFlag(key: string, ttlSeconds = 60): Promise<boolean> {
const cached = cache.get(key);
if (cached && cached.expires > Date.now()) {
return cached.value as boolean;
}
try {
const response = await fetch(process.env.FLAGSIGNALS_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.FLAGSIGNALS_API_KEY!,
},
body: JSON.stringify({ flags: [key] }),
});
const data = await response.json();
const value = data.flags[key]?.value ?? false;
cache.set(key, {
value,
expires: Date.now() + ttlSeconds * 1000,
});
return value;
} catch {
return cached?.value as boolean ?? false;
}
}
export async function middleware(request: NextRequest) {
const maintenanceMode = await getCachedFlag('maintenance_mode', 30);
if (maintenanceMode) {
return NextResponse.rewrite(new URL('/maintenance', request.url));
}
return NextResponse.next();
}Route Handlers
Use feature flags in API routes to control backend behavior.
Feature-Gated API Endpoints
import { NextResponse } from 'next/server';
import { getFlags } from '@/lib/flags';
export async function POST(request: Request) {
const flags = getFlags();
// Check if export feature is enabled
const exportEnabled = await flags.getFlag('enable_export', false);
if (!exportEnabled) {
return NextResponse.json(
{ error: 'Export feature is not available' },
{ status: 403 }
);
}
// Check which export formats are available
const { flags: exportFlags } = await flags.evaluate({
flags: ['export_csv', 'export_pdf', 'export_xlsx'],
});
const body = await request.json();
const { format } = body;
// Validate requested format is enabled
if (format === 'pdf' && !exportFlags.export_pdf?.value) {
return NextResponse.json(
{ error: 'PDF export is not available' },
{ status: 403 }
);
}
// Process export...
const data = await generateExport(format);
return NextResponse.json({ data });
}Rate Limiting with Flags
import { NextResponse } from 'next/server';
import { getFlags } from '@/lib/flags';
import { auth } from '@/lib/auth';
export async function POST(request: Request) {
const session = await auth();
const flags = getFlags();
// Get rate limit configuration from flags
const { flags: configFlags } = await flags.evaluate({
flags: ['ai_rate_limit', 'ai_premium_limit'],
context: {
userId: session?.user?.id,
plan: session?.user?.plan,
},
});
const rateLimit = session?.user?.plan === 'premium'
? (configFlags.ai_premium_limit?.value as number) ?? 100
: (configFlags.ai_rate_limit?.value as number) ?? 10;
// Check rate limit
const currentUsage = await getUserUsage(session?.user?.id);
if (currentUsage >= rateLimit) {
return NextResponse.json(
{ error: 'Rate limit exceeded' },
{ status: 429 }
);
}
// Process AI request...
}Server Actions
Use feature flags in Server Actions to control form behavior and mutations.
'use server';
import { getFlags } from '@/lib/flags';
import { revalidatePath } from 'next/cache';
export async function createPost(formData: FormData) {
const flags = getFlags();
// Check if new post creation flow is enabled
const useNewFlow = await flags.getFlag('new_post_flow', false);
const enableAITitles = await flags.getFlag('ai_title_suggestions', false);
const title = formData.get('title') as string;
const content = formData.get('content') as string;
let finalTitle = title;
// Use AI to suggest title if enabled and title is empty
if (enableAITitles && !title) {
finalTitle = await generateAITitle(content);
}
if (useNewFlow) {
// New creation flow with draft support
await createDraftPost({ title: finalTitle, content });
revalidatePath('/drafts');
return { success: true, redirect: '/drafts' };
}
// Legacy flow - publish immediately
await publishPost({ title: finalTitle, content });
revalidatePath('/posts');
return { success: true, redirect: '/posts' };
}
export async function deleteAccount() {
const flags = getFlags();
// Check if self-service deletion is enabled
const canSelfDelete = await flags.getFlag('self_service_deletion', false);
if (!canSelfDelete) {
return {
error: 'Please contact support to delete your account'
};
}
// Process deletion...
}Custom Hooks
Create reusable React hooks for common flag patterns.
useFeatureFlag Hook
'use client';
import { useState, useEffect, useCallback } from 'react';
interface UseFeatureFlagOptions {
defaultValue?: boolean;
refreshInterval?: number;
}
export function useFeatureFlag(
key: string,
options: UseFeatureFlagOptions = {}
) {
const { defaultValue = false, refreshInterval } = options;
const [enabled, setEnabled] = useState(defaultValue);
const [loading, setLoading] = useState(true);
const fetchFlag = useCallback(async () => {
try {
const response = await fetch('/api/flags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ flags: [key] }),
});
const data = await response.json();
setEnabled(data.flags[key]?.value ?? defaultValue);
} catch {
setEnabled(defaultValue);
} finally {
setLoading(false);
}
}, [key, defaultValue]);
useEffect(() => {
fetchFlag();
if (refreshInterval) {
const interval = setInterval(fetchFlag, refreshInterval);
return () => clearInterval(interval);
}
}, [fetchFlag, refreshInterval]);
return { enabled, loading, refresh: fetchFlag };
}
// Usage
function MyComponent() {
const { enabled: showBeta, loading } = useFeatureFlag('beta_features', {
refreshInterval: 60000, // Refresh every minute
});
if (loading) return <Skeleton />;
if (!showBeta) return null;
return <BetaFeatures />;
}useExperiment Hook
A hook for A/B testing with variant support:
'use client';
import { useState, useEffect } from 'react';
type Variant = 'control' | 'variant_a' | 'variant_b';
interface UseExperimentResult {
variant: Variant;
loading: boolean;
isControl: boolean;
isVariantA: boolean;
isVariantB: boolean;
}
export function useExperiment(experimentKey: string): UseExperimentResult {
const [variant, setVariant] = useState<Variant>('control');
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchExperiment() {
try {
const response = await fetch('/api/flags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ flags: [experimentKey] }),
});
const data = await response.json();
setVariant(data.flags[experimentKey]?.value ?? 'control');
} finally {
setLoading(false);
}
}
fetchExperiment();
}, [experimentKey]);
return {
variant,
loading,
isControl: variant === 'control',
isVariantA: variant === 'variant_a',
isVariantB: variant === 'variant_b',
};
}
// Usage
function PricingPage() {
const { variant, loading, isVariantA } = useExperiment('pricing_experiment');
if (loading) return <Skeleton />;
if (isVariantA) {
return <PricingWithAnnualToggle />;
}
return <DefaultPricing />;
}Context Provider
Create a React Context to share flags across your application.
'use client';
import { createContext, useContext, ReactNode } from 'react';
interface FlagValue {
value: unknown;
reason: string;
}
interface FlagsContextType {
flags: Record<string, FlagValue>;
getFlag: <T>(key: string, defaultValue: T) => T;
isEnabled: (key: string) => boolean;
}
const FlagsContext = createContext<FlagsContextType | null>(null);
interface FlagsProviderProps {
children: ReactNode;
initialFlags: Record<string, FlagValue>;
}
export function FlagsProvider({ children, initialFlags }: FlagsProviderProps) {
const getFlag = <T,>(key: string, defaultValue: T): T => {
return (initialFlags[key]?.value as T) ?? defaultValue;
};
const isEnabled = (key: string): boolean => {
return initialFlags[key]?.value === true;
};
return (
<FlagsContext.Provider value={{ flags: initialFlags, getFlag, isEnabled }}>
{children}
</FlagsContext.Provider>
);
}
export function useFlags() {
const context = useContext(FlagsContext);
if (!context) {
throw new Error('useFlags must be used within a FlagsProvider');
}
return context;
}Set up the provider in your layout:
import { getFlags } from '@/lib/flags';
import { FlagsProvider } from '@/providers/flags-provider';
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
// Fetch all flags needed by client components
const flagsClient = getFlags();
const { flags } = await flagsClient.evaluate({
flags: [
'dark_mode',
'new_navigation',
'enable_chat',
'show_notifications',
],
});
return (
<html lang="en">
<body>
<FlagsProvider initialFlags={flags}>
{children}
</FlagsProvider>
</body>
</html>
);
}Use flags anywhere in your client components:
'use client';
import { useFlags } from '@/providers/flags-provider';
export function Navigation() {
const { isEnabled, getFlag } = useFlags();
return (
<nav>
<Logo />
{isEnabled('new_navigation') ? (
<NewNavLinks />
) : (
<LegacyNavLinks />
)}
{isEnabled('enable_chat') && <ChatButton />}
{isEnabled('show_notifications') && (
<NotificationBell count={getFlag('notification_limit', 5)} />
)}
</nav>
);
}Conditional Rendering
Create reusable components for flag-based rendering.
Feature Component
'use client';
import { useFlags } from '@/providers/flags-provider';
import { ReactNode } from 'react';
interface FeatureProps {
flag: string;
children: ReactNode;
fallback?: ReactNode;
}
export function Feature({ flag, children, fallback = null }: FeatureProps) {
const { isEnabled } = useFlags();
if (!isEnabled(flag)) {
return <>{fallback}</>;
}
return <>{children}</>;
}
// Usage
function Dashboard() {
return (
<div>
<Feature flag="new_analytics" fallback={<LegacyAnalytics />}>
<NewAnalyticsDashboard />
</Feature>
<Feature flag="export_button">
<ExportButton />
</Feature>
</div>
);
}Server-Side Feature Component
import { getFlags } from '@/lib/flags';
import { ReactNode } from 'react';
interface ServerFeatureProps {
flag: string;
children: ReactNode;
fallback?: ReactNode;
}
export async function ServerFeature({
flag,
children,
fallback = null
}: ServerFeatureProps) {
const flags = getFlags();
const enabled = await flags.getFlag(flag, false);
if (!enabled) {
return <>{fallback}</>;
}
return <>{children}</>;
}
// Usage in a Server Component
async function Page() {
return (
<div>
<ServerFeature flag="hero_video" fallback={<StaticHero />}>
<VideoHero />
</ServerFeature>
</div>
);
}Caching Strategies
Optimize performance with proper caching configuration.
Next.js Fetch Caching
export async function evaluateFlags(options: EvaluateOptions = {}) {
const response = await fetch(process.env.FLAGSIGNALS_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.FLAGSIGNALS_API_KEY!,
},
body: JSON.stringify(options),
// Cache options:
// Time-based revalidation (recommended)
next: { revalidate: 60 }, // Cache for 60 seconds
// Or use tags for on-demand revalidation
// next: { tags: ['flags'] },
// Or disable caching entirely
// cache: 'no-store',
});
return response.json();
}
// Revalidate flags on-demand (e.g., from a webhook)
import { revalidateTag } from 'next/cache';
export async function revalidateFlags() {
revalidateTag('flags');
}Request Deduplication
Next.js automatically deduplicates fetch requests. Use React cache for additional optimization:
import { cache } from 'react';
// This function will only run once per request,
// even if called multiple times in different components
export const getFlags = cache(async (flagKeys?: string[]) => {
const response = await fetch(process.env.FLAGSIGNALS_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.FLAGSIGNALS_API_KEY!,
},
body: JSON.stringify({ flags: flagKeys }),
next: { revalidate: 60 },
});
return response.json();
});
// Now multiple components can call getFlags()
// but only one request will be made per renderClient-Side Caching
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
interface CacheEntry {
data: Record<string, unknown>;
timestamp: number;
}
const cache: Map<string, CacheEntry> = new Map();
const CACHE_TTL = 60 * 1000; // 1 minute
export function useCachedFlags(flagKeys: string[]) {
const cacheKey = flagKeys.sort().join(',');
const [flags, setFlags] = useState<Record<string, unknown>>({});
const [loading, setLoading] = useState(true);
const fetchingRef = useRef(false);
const fetchFlags = useCallback(async (force = false) => {
// Check cache first
const cached = cache.get(cacheKey);
if (!force && cached && Date.now() - cached.timestamp < CACHE_TTL) {
setFlags(cached.data);
setLoading(false);
return;
}
// Prevent duplicate fetches
if (fetchingRef.current) return;
fetchingRef.current = true;
try {
const response = await fetch('/api/flags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ flags: flagKeys }),
});
const data = await response.json();
// Update cache
cache.set(cacheKey, {
data: data.flags,
timestamp: Date.now(),
});
setFlags(data.flags);
} finally {
setLoading(false);
fetchingRef.current = false;
}
}, [cacheKey, flagKeys]);
useEffect(() => {
fetchFlags();
}, [fetchFlags]);
return { flags, loading, refresh: () => fetchFlags(true) };
}Error Handling
Handle flag evaluation failures gracefully with fallbacks.
Graceful Degradation
interface FlagDefaults {
[key: string]: unknown;
}
const FLAG_DEFAULTS: FlagDefaults = {
new_dashboard: false,
enable_chat: false,
rate_limit: 100,
theme: 'light',
};
export async function getFlag<T>(key: string): Promise<T> {
const defaultValue = FLAG_DEFAULTS[key] as T;
try {
const response = await fetch(process.env.FLAGSIGNALS_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.FLAGSIGNALS_API_KEY!,
},
body: JSON.stringify({ flags: [key] }),
next: { revalidate: 60 },
});
if (!response.ok) {
console.error(`Flag fetch failed: ${response.status}`);
return defaultValue;
}
const data = await response.json();
return (data.flags[key]?.value as T) ?? defaultValue;
} catch (error) {
console.error('Flag evaluation error:', error);
return defaultValue;
}
}Error Boundary for Flags
'use client';
import { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback: ReactNode;
}
interface State {
hasError: boolean;
}
export class FlagErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error: Error) {
console.error('Flag evaluation error:', error);
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// Usage
function App() {
return (
<FlagErrorBoundary fallback={<DefaultFeature />}>
<FlaggedFeature />
</FlagErrorBoundary>
);
}Timeout Handling
async function fetchWithTimeout(
url: string,
options: RequestInit,
timeoutMs = 3000
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
return response;
} finally {
clearTimeout(timeout);
}
}
export async function getFlags(flagKeys?: string[]) {
try {
const response = await fetchWithTimeout(
process.env.FLAGSIGNALS_URL!,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.FLAGSIGNALS_API_KEY!,
},
body: JSON.stringify({ flags: flagKeys }),
},
3000 // 3 second timeout
);
return response.json();
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
console.error('Flag fetch timed out');
}
// Return empty flags on error
return { flags: {}, evaluated_at: new Date().toISOString() };
}
}TypeScript
Get full type safety for your feature flags.
Type-Safe Flag Definitions
// Define your flag types
export interface AppFlags {
// Boolean flags
new_dashboard: boolean;
enable_chat: boolean;
dark_mode: boolean;
maintenance_mode: boolean;
// String flags
hero_variant: 'default' | 'minimal' | 'video';
theme: 'light' | 'dark' | 'system';
// Number flags
rate_limit: number;
max_uploads: number;
// Object flags
feature_config: {
enableAnalytics: boolean;
maxRetries: number;
};
}
// Type-safe flag keys
export type FlagKey = keyof AppFlags;
// Helper to get flag value type
export type FlagValue<K extends FlagKey> = AppFlags[K];Type-Safe Client
import type { AppFlags, FlagKey, FlagValue } from '@/types/flags';
interface FlagResponse<K extends FlagKey> {
value: FlagValue<K>;
reason: 'default' | 'disabled' | 'targeted';
}
interface FlagsResponse {
flags: {
[K in FlagKey]?: FlagResponse<K>;
};
evaluated_at: string;
}
class TypedFlagsClient {
private baseUrl: string;
private apiKey: string;
constructor() {
this.baseUrl = process.env.FLAGSIGNALS_URL!;
this.apiKey = process.env.FLAGSIGNALS_API_KEY!;
}
async getFlag<K extends FlagKey>(
key: K,
defaultValue: FlagValue<K>
): Promise<FlagValue<K>> {
try {
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
},
body: JSON.stringify({ flags: [key] }),
next: { revalidate: 60 },
});
const data: FlagsResponse = await response.json();
return data.flags[key]?.value ?? defaultValue;
} catch {
return defaultValue;
}
}
async getFlags<K extends FlagKey>(
keys: K[]
): Promise<{ [P in K]?: FlagValue<P> }> {
const response = await fetch(this.baseUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': this.apiKey,
},
body: JSON.stringify({ flags: keys }),
next: { revalidate: 60 },
});
const data: FlagsResponse = await response.json();
return keys.reduce((acc, key) => {
acc[key] = data.flags[key]?.value;
return acc;
}, {} as { [P in K]?: FlagValue<P> });
}
}
export const flags = new TypedFlagsClient();
// Usage - fully typed!
const heroVariant = await flags.getFlag('hero_variant', 'default');
// heroVariant is typed as: 'default' | 'minimal' | 'video'
const rateLimit = await flags.getFlag('rate_limit', 100);
// rateLimit is typed as: numberTesting
Test your feature flag integrations effectively.
Mocking Flags in Tests
import type { AppFlags, FlagKey, FlagValue } from '@/types/flags';
// Mock flags store
let mockFlags: Partial<AppFlags> = {};
export function setMockFlag<K extends FlagKey>(
key: K,
value: FlagValue<K>
) {
mockFlags[key] = value;
}
export function setMockFlags(flags: Partial<AppFlags>) {
mockFlags = { ...mockFlags, ...flags };
}
export function clearMockFlags() {
mockFlags = {};
}
// Mock implementation
export const mockFlagsClient = {
async getFlag<K extends FlagKey>(
key: K,
defaultValue: FlagValue<K>
): Promise<FlagValue<K>> {
return (mockFlags[key] as FlagValue<K>) ?? defaultValue;
},
async evaluate(options: { flags?: string[] } = {}) {
const requestedFlags = options.flags || Object.keys(mockFlags);
const flags: Record<string, { value: unknown; reason: string }> = {};
for (const key of requestedFlags) {
if (key in mockFlags) {
flags[key] = {
value: mockFlags[key as FlagKey],
reason: 'default',
};
}
}
return { flags, evaluated_at: new Date().toISOString() };
},
};Component Tests
import { render, screen } from '@testing-library/react';
import { setMockFlags, clearMockFlags } from '@/lib/flags.mock';
import Dashboard from '@/app/dashboard/page';
// Mock the flags module
jest.mock('@/lib/flags', () => require('@/lib/flags.mock'));
describe('Dashboard', () => {
afterEach(() => {
clearMockFlags();
});
it('shows new dashboard when flag is enabled', async () => {
setMockFlags({ new_dashboard: true });
render(await Dashboard());
expect(screen.getByTestId('new-dashboard')).toBeInTheDocument();
expect(screen.queryByTestId('legacy-dashboard')).not.toBeInTheDocument();
});
it('shows legacy dashboard when flag is disabled', async () => {
setMockFlags({ new_dashboard: false });
render(await Dashboard());
expect(screen.getByTestId('legacy-dashboard')).toBeInTheDocument();
expect(screen.queryByTestId('new-dashboard')).not.toBeInTheDocument();
});
it('shows chat widget only when enabled', async () => {
setMockFlags({
new_dashboard: true,
enable_chat: true,
});
render(await Dashboard());
expect(screen.getByTestId('chat-widget')).toBeInTheDocument();
});
});Integration Tests
import { createMocks } from 'node-mocks-http';
import { POST } from '@/app/api/flags/route';
// Mock fetch globally
global.fetch = jest.fn();
describe('Flags API Route', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('returns flags from FlagSignals', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({
flags: {
test_flag: { value: true, reason: 'default' },
},
evaluated_at: '2024-01-01T00:00:00Z',
}),
});
const request = new Request('http://localhost/api/flags', {
method: 'POST',
body: JSON.stringify({ flags: ['test_flag'] }),
});
const response = await POST(request);
const data = await response.json();
expect(data.flags.test_flag.value).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
process.env.FLAGSIGNALS_URL,
expect.objectContaining({
method: 'POST',
headers: expect.objectContaining({
'x-api-key': process.env.FLAGSIGNALS_API_KEY,
}),
})
);
});
it('handles errors gracefully', async () => {
(global.fetch as jest.Mock).mockRejectedValueOnce(new Error('Network error'));
const request = new Request('http://localhost/api/flags', {
method: 'POST',
body: JSON.stringify({ flags: ['test_flag'] }),
});
const response = await POST(request);
expect(response.status).toBe(500);
});
});A/B Testing
FlagSignals includes built-in A/B testing capabilities. Run experiments on your feature flags to measure the impact of changes with statistical significance.
Users are deterministically assigned to variants based on their userId, ensuring consistent experience.
Built-in analytics calculate p-values and confidence levels to validate your results.
How It Works
- Create an experiment on any feature flag with control and treatment variants
- When users request the flag, they're automatically assigned to a variant
- Track conversions when users complete the desired action
- View real-time analytics to measure impact and statistical significance
Note: A/B testing requires a Starter plan or higher. Only one experiment can run per flag at a time.
Running Experiments
Experiments are automatically evaluated when you include a userId in the context. The response includes experiment metadata.
Evaluating with Experiments
// Include userId in context for experiment assignment
const response = await fetch(process.env.FLAGSIGNALS_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.FLAGSIGNALS_API_KEY!,
},
body: JSON.stringify({
flags: ['checkout_flow'],
context: {
userId: session.user.id, // Required for experiments
email: session.user.email,
},
}),
});
const { flags } = await response.json();
const checkout = flags.checkout_flow;
// Check if user is in an experiment
if (checkout.experimentId) {
console.log(`User in experiment: ${checkout.variant}`);
}
// Use the flag value as normal
if (checkout.value) {
return <NewCheckoutFlow />;
}
return <LegacyCheckoutFlow />;Experiment Response Format
When a flag has an active experiment, the response includes additional fields:
{
"flags": {
"checkout_flow": {
"value": true,
"reason": "experiment",
"experimentId": "550e8400-e29b-41d4-a716-446655440000",
"variant": "Treatment"
}
},
"evaluated_at": "2024-01-15T10:30:00.000Z"
}React Hook for Experiments
'use client';
import { useState, useEffect } from 'react';
interface ExperimentResult {
value: unknown;
variant: string | null;
experimentId: string | null;
loading: boolean;
}
export function useExperiment(flagKey: string, userId: string): ExperimentResult {
const [result, setResult] = useState<ExperimentResult>({
value: null,
variant: null,
experimentId: null,
loading: true,
});
useEffect(() => {
async function evaluate() {
const response = await fetch('/api/flags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
flags: [flagKey],
context: { userId },
}),
});
const { flags } = await response.json();
const flag = flags[flagKey];
setResult({
value: flag?.value,
variant: flag?.variant || null,
experimentId: flag?.experimentId || null,
loading: false,
});
}
evaluate();
}, [flagKey, userId]);
return result;
}
// Usage
function PricingPage({ userId }: { userId: string }) {
const { value, variant, loading } = useExperiment('pricing_test', userId);
if (loading) return <Skeleton />;
return variant === 'Treatment' ? <NewPricing /> : <OldPricing />;
}Tracking Conversions
Track when users complete the desired action to measure your experiment's success. Use the /track endpoint to record conversion events.
Basic Conversion Tracking
export async function trackConversion(
experimentId: string,
userId: string,
options?: {
eventName?: string;
eventValue?: number;
metadata?: Record<string, unknown>;
}
) {
const response = await fetch(process.env.FLAGSIGNALS_TRACK_URL!, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.FLAGSIGNALS_API_KEY!,
},
body: JSON.stringify({
experimentId,
userId,
eventName: options?.eventName || 'conversion',
eventValue: options?.eventValue,
metadata: options?.metadata,
}),
});
return response.json();
}Example: E-commerce Checkout
'use server';
import { trackConversion } from '@/lib/tracking';
export async function completeCheckout(formData: FormData) {
const session = await auth();
const orderId = formData.get('orderId') as string;
const total = parseFloat(formData.get('total') as string);
// Process the order...
await processOrder(orderId);
// Get the experiment ID from the session or cookie
const experimentId = await getActiveExperimentId(session.user.id, 'checkout_flow');
// Track the conversion with revenue
if (experimentId) {
await trackConversion(experimentId, session.user.id, {
eventName: 'purchase',
eventValue: total,
metadata: {
orderId,
currency: 'USD',
},
});
}
redirect('/order-confirmation');
}Client-Side Tracking
'use client';
import { useState } from 'react';
interface SignupFormProps {
experimentId: string | null;
userId: string;
}
export function SignupForm({ experimentId, userId }: SignupFormProps) {
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
// Submit the form...
await submitSignup();
// Track conversion if in an experiment
if (experimentId) {
await fetch('/api/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
experimentId,
userId,
eventName: 'signup',
}),
});
}
setLoading(false);
}
return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<button type="submit" disabled={loading}>
{loading ? 'Signing up...' : 'Sign Up'}
</button>
</form>
);
}Multiple Event Types
Track different conversion events for the same experiment:
// Track page view
await trackConversion(experimentId, userId, {
eventName: 'page_view',
});
// Track add to cart
await trackConversion(experimentId, userId, {
eventName: 'add_to_cart',
eventValue: 49.99,
});
// Track purchase (primary conversion)
await trackConversion(experimentId, userId, {
eventName: 'conversion',
eventValue: 149.99,
});Experiment Analytics
FlagSignals provides real-time analytics for your experiments, including conversion rates, statistical significance, and daily breakdowns.
Analytics Dashboard
Access experiment analytics from the flag detail page by clicking "View Analytics" in the experiment dropdown. The dashboard shows:
- Total Users: Number of users assigned to each variant
- Conversions: Number of users who converted in each variant
- Conversion Rates: Percentage of users who converted
- Relative Improvement: Lift of treatment over control
- Statistical Significance: Confidence level and p-value
- Daily Activity: Chart of assignments and conversions over time
Statistical Significance
FlagSignals uses a two-proportion z-test to calculate statistical significance. Results are considered significant when:
- p-value is less than 0.05 (95% confidence)
- Both variants have sufficient sample size
- The experiment has run long enough to account for variance
Best Practices
Wait for significance
Don't make decisions until you reach statistical significance. Early results can be misleading.
Plan your sample size
Larger effect sizes need fewer users. Plan your traffic allocation accordingly.
One change at a time
Test one variable per experiment to clearly attribute results.
Document your hypothesis
Record what you expect to happen before running the experiment.
API Reference
Complete reference for the FlagSignals API.
Evaluate Endpoint
/functions/v1/evaluateHeaders
Content-Type: application/json
x-api-key: your_environment_api_key
Request Body
{
"flags": ["flag_key_1", "flag_key_2"], // Optional: specific flags
"context": { // Optional: targeting context
"userId": "user_123",
"email": "user@example.com",
"plan": "pro",
"customAttribute": "value"
}
}Track Endpoint
Record conversion events for A/B experiments.
/functions/v1/trackHeaders
Content-Type: application/json
x-api-key: your_environment_api_key
Request Body
{
"experimentId": "550e8400-e29b-41d4-a716-446655440000",
"userId": "user_123",
"eventName": "conversion", // Optional, defaults to "conversion"
"eventValue": 99.99, // Optional, for revenue tracking
"metadata": { // Optional, additional context
"plan": "pro",
"source": "landing_page"
}
}Success Response (200)
{
"success": true,
"variant": "treatment",
"eventName": "conversion"
}Error Responses
Response Format
Success Response (200)
{
"flags": {
"new_dashboard": {
"value": true,
"reason": "default"
},
"hero_variant": {
"value": "minimal",
"reason": "targeted"
},
"rate_limit": {
"value": 100,
"reason": "default"
}
},
"evaluated_at": "2024-01-15T10:30:00.000Z"
}Error Responses
Reason Values
default- Flag is enabled, returning default valuedisabled- Flag is disabled for this environmenttargeted- Value determined by targeting rulesexperiment- Value determined by A/B experiment assignment