⚠️ This is a demo site only. Do not use for production workloads.
Documentation

FlagSignals for Next.js

A comprehensive guide to integrating feature flags into your Next.js application using FlagSignals.

Server-First

Evaluate flags on the server for zero layout shift and instant rendering.

Type-Safe

Full TypeScript support with strongly-typed flag values.

Edge-Ready

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

.env.local
FLAGSIGNALS_API_KEY=your_api_key_here
FLAGSIGNALS_URL=https://your-project.supabase.co/functions/v1/evaluate

3. Create a flags utility

lib/flags.ts
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

app/page.tsx
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:

lib/flags.ts
// 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

app/dashboard/page.tsx
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:

app/page.tsx
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:

app/settings/page.tsx
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:

app/layout.tsx
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:

app/page.tsx
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}
    />
  );
}
components/interactive-feature.tsx
'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:

hooks/use-flags.ts
'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):

app/api/flags/route.ts
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

middleware.ts
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:

middleware.ts
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:

middleware.ts
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

app/api/export/route.ts
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

app/api/ai/route.ts
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.

app/actions.ts
'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

hooks/use-feature-flag.ts
'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:

hooks/use-experiment.ts
'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.

providers/flags-provider.tsx
'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:

app/layout.tsx
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:

components/navigation.tsx
'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

components/feature.tsx
'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

components/server-feature.tsx
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

lib/flags.ts
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:

lib/flags.ts
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 render

Client-Side Caching

hooks/use-cached-flags.ts
'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

lib/flags.ts
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

components/flag-error-boundary.tsx
'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

lib/flags.ts
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

types/flags.ts
// 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

lib/flags.ts
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: number

Testing

Test your feature flag integrations effectively.

Mocking Flags in Tests

lib/flags.mock.ts
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

__tests__/dashboard.test.tsx
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

__tests__/api/flags.test.ts
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.

Sticky Assignments

Users are deterministically assigned to variants based on their userId, ensuring consistent experience.

Statistical Significance

Built-in analytics calculate p-values and confidence levels to validate your results.

How It Works

  1. Create an experiment on any feature flag with control and treatment variants
  2. When users request the flag, they're automatically assigned to a variant
  3. Track conversions when users complete the desired action
  4. 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

lib/experiment.ts
// 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

hooks/use-experiment.ts
'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

lib/tracking.ts
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

app/checkout/actions.ts
'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

components/signup-form.tsx
'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

POST/functions/v1/evaluate

Headers

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.

POST/functions/v1/track

Headers

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

400Missing experimentId or userId, or user not assigned to experiment
401Missing or invalid API key
403Experiment does not belong to this project
404Experiment not found

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

401Missing or invalid API key
403Subscription inactive or trial expired
429API request limit exceeded

Reason Values

  • default - Flag is enabled, returning default value
  • disabled - Flag is disabled for this environment
  • targeted - Value determined by targeting rules
  • experiment - Value determined by A/B experiment assignment

Ready to get started?

Create your first feature flag in minutes.