Feature Flags
Implement feature flags for gradual rollouts, plan-gated features, A/B testing, and safe deployments.
When to Use Feature Flags
- Plan gating: Free users see X, Pro users see X + Y
- Gradual rollout: Release to 5% of users, then 25%, then 100%
- A/B testing: Test two versions of a feature against metrics
- Kill switches: Instantly disable a feature without deploying
- Beta access: Let specific users opt into early features
- Operational flags: Toggle maintenance mode, rate limits, feature deprecation
Option 1: PostHog Feature Flags (Recommended)
PostHog provides feature flags with built-in analytics. Best for most SaaS apps.
Setup
pnpm add posthog-js posthog-node
Server-Side Evaluation
// src/lib/posthog.ts
import { PostHog } from "posthog-node"
const posthog = new PostHog(process.env.POSTHOG_API_KEY!, {
host: process.env.POSTHOG_HOST || "https://us.i.posthog.com",
})
export async function isFeatureEnabled(
flagKey: string,
userId: string,
properties?: Record<string, unknown>
): Promise<boolean> {
return await posthog.isFeatureEnabled(flagKey, userId, {
personProperties: properties,
}) ?? false
}
export async function getFeatureFlag(
flagKey: string,
userId: string,
properties?: Record<string, unknown>
): Promise<string | boolean | undefined> {
return await posthog.getFeatureFlag(flagKey, userId, {
personProperties: properties,
})
}
// Batch evaluation for multiple flags
export async function getAllFlags(
userId: string,
properties?: Record<string, unknown>
): Promise<Record<string, boolean | string>> {
return await posthog.getAllFlags(userId, {
personProperties: properties,
})
}
Client-Side Provider
// src/components/posthog-provider.tsx
"use client"
import posthog from "posthog-js"
import { PostHogProvider as PHProvider } from "posthog-js/react"
import { useEffect } from "react"
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com",
capture_pageview: false, // manual pageview tracking
})
}, [])
return <PHProvider client={posthog}>{children}</PHProvider>
}
React Hook Usage
"use client"
import { useFeatureFlagEnabled, useFeatureFlagVariantKey } from "posthog-js/react"
export function PricingPage() {
const showNewPricing = useFeatureFlagEnabled("new-pricing-page")
const pricingVariant = useFeatureFlagVariantKey("pricing-experiment")
if (showNewPricing) {
return <NewPricingPage variant={pricingVariant} />
}
return <CurrentPricingPage />
}
Server Component Usage
// src/app/(app)/dashboard/page.tsx
import { isFeatureEnabled } from "@/lib/posthog"
import { getCurrentUser } from "@/lib/auth"
export default async function DashboardPage() {
const user = await getCurrentUser()
const showAnalytics = await isFeatureEnabled("dashboard-analytics", user.id, {
plan: user.plan,
})
return (
<div>
<DashboardOverview />
{showAnalytics && <AnalyticsPanel />}
</div>
)
}
Option 2: Custom Feature Flags (Database-Backed)
For full control without third-party dependencies. Good for plan-gating and simple rollouts.
Schema
export const featureFlags = pgTable("feature_flags", {
id: uuid("id").primaryKey().defaultRandom(),
key: text("key").notNull().unique(),
name: text("name").notNull(),
description: text("description"),
enabled: boolean("enabled").notNull().default(false),
rolloutPercentage: integer("rollout_percentage").notNull().default(100), // 0-100
allowedPlans: text("allowed_plans").array(), // null = all plans
allowedUserIds: text("allowed_user_ids").array(), // explicit allowlist
metadata: jsonb("metadata"), // arbitrary config
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
})
Flag Evaluation Engine
import { createHash } from "crypto"
import { eq } from "drizzle-orm"
import { db } from "@/db"
import * as schema from "@/db/schema"
// In-memory cache with TTL
const flagCache = new Map<string, { flag: typeof schema.featureFlags.$inferSelect; expiresAt: number }>()
const CACHE_TTL = 30_000 // 30 seconds
async function getFlag(key: string) {
const cached = flagCache.get(key)
if (cached && cached.expiresAt > Date.now()) {
return cached.flag
}
const flag = await db.query.featureFlags.findFirst({
where: eq(schema.featureFlags.key, key),
})
if (flag) {
flagCache.set(key, { flag, expiresAt: Date.now() + CACHE_TTL })
}
return flag ?? null
}
export async function isEnabled(
flagKey: string,
context: { userId: string; plan?: string }
): Promise<boolean> {
const flag = await getFlag(flagKey)
if (!flag || !flag.enabled) return false
// Check explicit allowlist
if (flag.allowedUserIds?.includes(context.userId)) return true
// Check plan restriction
if (flag.allowedPlans && context.plan) {
if (!flag.allowedPlans.includes(context.plan)) return false
}
// Check percentage rollout (deterministic based on userId + flagKey)
if (flag.rolloutPercentage < 100) {
const hash = createHash("md5")
.update(`${flagKey}:${context.userId}`)
.digest("hex")
const bucket = parseInt(hash.substring(0, 8), 16) % 100
if (bucket >= flag.rolloutPercentage) return false
}
return true
}
// Invalidate cache when flags are updated
export function invalidateFlagCache(key?: string) {
if (key) {
flagCache.delete(key)
} else {
flagCache.clear()
}
}
Plan-Gated Feature Helper
// Convenience wrapper for the most common pattern: plan gating
const PLAN_FEATURES: Record<string, string[]> = {
free: ["basic-dashboard", "single-project"],
pro: ["basic-dashboard", "single-project", "analytics", "api-access", "custom-domain"],
enterprise: ["basic-dashboard", "single-project", "analytics", "api-access", "custom-domain", "sso", "audit-log", "priority-support"],
}
export function hasFeatureForPlan(featureKey: string, plan: string): boolean {
return PLAN_FEATURES[plan]?.includes(featureKey) ?? false
}
// React component
export function FeatureGate({
feature,
plan,
children,
fallback,
}: {
feature: string
plan: string
children: React.ReactNode
fallback?: React.ReactNode
}) {
if (!hasFeatureForPlan(feature, plan)) {
return fallback ?? null
}
return <>{children}</>
}
// Usage
<FeatureGate feature="analytics" plan={user.plan} fallback={<UpgradePrompt feature="analytics" />}>
<AnalyticsDashboard />
</FeatureGate>
Option 3: LaunchDarkly
Enterprise-grade feature management. Best for large teams with complex targeting rules.
Setup
pnpm add @launchdarkly/node-server-sdk @launchdarkly/react-client-sdk
Server-Side
import * as LaunchDarkly from "@launchdarkly/node-server-sdk"
const ldClient = LaunchDarkly.init(process.env.LAUNCHDARKLY_SDK_KEY!)
await ldClient.waitForInitialization()
export async function isFeatureEnabled(
flagKey: string,
user: { id: string; email: string; plan: string }
): Promise<boolean> {
const context: LaunchDarkly.LDContext = {
kind: "user",
key: user.id,
email: user.email,
custom: { plan: user.plan },
}
return await ldClient.variation(flagKey, context, false)
}
Gradual Rollout Strategy
Phase 1: Internal (0-1%)
Target: Team members only (allowlist)
Duration: 1-3 days
Metrics: Error rates, performance
Decision: Fix bugs, proceed or kill
Phase 2: Canary (1-5%)
Target: Random 5% of users
Duration: 3-7 days
Metrics: Error rates, user engagement, support tickets
Decision: Increase or rollback
Phase 3: Expansion (5-50%)
Target: Gradual increase by segment
Duration: 1-2 weeks
Metrics: Business metrics (conversion, retention)
Decision: Full rollout or iterate
Phase 4: General Availability (100%)
Target: All users
Duration: Permanent (then clean up the flag)
Metrics: Confirm no regression
Action: Remove flag from code, delete from system
A/B Testing Integration
Use feature flag variants to run A/B tests.
PostHog A/B Test
// Define experiment with variants in PostHog dashboard
// Then evaluate in code:
const variant = await getFeatureFlag("signup-experiment", userId)
switch (variant) {
case "control":
return <SignupFormCurrent />
case "social-first":
return <SignupFormSocialFirst />
case "minimal":
return <SignupFormMinimal />
default:
return <SignupFormCurrent />
}
// Track conversion events
posthog.capture("signup_completed", {
experiment: "signup-experiment",
variant,
})
Custom A/B Framework
export function assignVariant(
experimentKey: string,
userId: string,
variants: string[]
): string {
// Deterministic assignment -- same user always gets same variant
const hash = createHash("md5")
.update(`${experimentKey}:${userId}`)
.digest("hex")
const bucket = parseInt(hash.substring(0, 8), 16) % variants.length
return variants[bucket]
}
// Track experiment exposure
export async function trackExposure(
experimentKey: string,
userId: string,
variant: string
) {
await db.insert(schema.experimentExposures).values({
experimentKey,
userId,
variant,
exposedAt: new Date(),
})
}
Flag Lifecycle Management
Naming Convention
{scope}-{feature}-{purpose}
Examples:
billing-annual-plans-enabled # permanent operational flag
dashboard-analytics-v2-rollout # temporary rollout flag
signup-social-first-experiment # temporary A/B test
maintenance-mode # operational kill switch
Cleaning Up Old Flags
Stale feature flags are tech debt. Clean them up.
// scripts/audit-feature-flags.ts
import { db } from "@/db"
const flags = await db.query.featureFlags.findMany()
for (const flag of flags) {
const ageInDays = (Date.now() - flag.createdAt.getTime()) / (1000 * 60 * 60 * 24)
if (flag.rolloutPercentage === 100 && ageInDays > 30) {
console.log(`STALE: ${flag.key} -- 100% for ${Math.round(ageInDays)} days. Remove flag and hardcode.`)
}
if (!flag.enabled && ageInDays > 90) {
console.log(`DEAD: ${flag.key} -- disabled for ${Math.round(ageInDays)} days. Delete.`)
}
}
Cleanup Checklist
- Search codebase for the flag key
- Remove all conditional branches
- Keep the "enabled" code path, delete the "disabled" path
- Delete the flag from the database / PostHog / LaunchDarkly
- Remove any related A/B test tracking
- Commit with message:
chore: remove feature flag {key} (fully rolled out)
Common Gotchas
- Deterministic hashing: Use userId + flagKey for consistent bucket assignment -- never
Math.random() - Default to disabled: If flag evaluation fails (network error, missing flag), return
false - Cache flags: Don't hit the database on every request. Use in-memory cache with 30s TTL
- Don't nest flags:
if (flagA && flagB && flagC)is a maintenance nightmare - Track exposure: Always log when a user sees a flagged feature -- critical for A/B test validity
- Plan gating is not a feature flag: Use a simple plan-to-features mapping for static plan gating. Reserve flags for dynamic rollouts
- Clean up flags: Set calendar reminders. A codebase with 200 stale flags is worse than no flags at all