Long-lived feature branches are a lie we tell ourselves. 'I'll merge it when it's done' turns into three weeks of merge conflicts, a broken main, and a deploy that goes out at 11pm on a Friday because you've been putting it off. We've been there. The fix isn't better branching strategy — it's shipping incomplete features hidden behind a flag.
Feature flags (also called feature toggles) let you merge code into main every day, even when that code isn't ready for users. You control who sees what at runtime — not at deploy time. It sounds like extra infrastructure, but honestly it's less work than maintaining a feature branch for three weeks.
The Simplest Possible Feature Flag
Before reaching for a third-party tool, let's talk about what a feature flag actually is at its core. It's a condition. If flag is on, render the new thing. If not, render the old thing. That's it. The sophistication comes from where you store the flag state and who can change it.
The absolute minimum viable approach: environment variables. Ship the feature hidden behind a check, enable it per environment.
// lib/flags.ts
export const flags = {
newDashboard: process.env.NEXT_PUBLIC_FLAG_NEW_DASHBOARD === 'true',
betaCheckout: process.env.NEXT_PUBLIC_FLAG_BETA_CHECKOUT === 'true',
} as const;
export type FlagKey = keyof typeof flags;Then in your component:
import { flags } from '@/lib/flags';
export default function DashboardPage() {
if (flags.newDashboard) {
return <NewDashboard />;
}
return <OldDashboard />;
}This works fine for environment-level toggles. Production gets the old dashboard, staging gets the new one. Simple, zero dependencies, no latency. The downside: you need a redeploy to change a flag. If the new dashboard breaks in production, you're rolling back a deploy at 2am instead of just flipping a toggle.
Runtime Flags Without the $500/Month Bill
The real power of feature flags is changing them without deploying. You ship the code, flip a toggle, and users see the new feature — no deploy, no downtime, instant rollback if something goes wrong. For this you need the flag state stored somewhere your app can read at runtime.
The lazy but effective approach: a flags table in your database. Dead simple, free, and you already have a database.
// db/schema.ts (Drizzle)
import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core';
export const featureFlags = pgTable('feature_flags', {
key: text('key').primaryKey(),
enabled: boolean('enabled').notNull().default(false),
description: text('description'),
updatedAt: timestamp('updated_at').defaultNow(),
});// lib/flags.ts — server-side flag lookup with caching
import { db } from '@/db';
import { featureFlags } from '@/db/schema';
import { eq } from 'drizzle-orm';
import { cache } from 'react';
// React's cache() deduplicates calls within a single request
export const getFlag = cache(async (key: string): Promise<boolean> => {
const flag = await db
.select({ enabled: featureFlags.enabled })
.from(featureFlags)
.where(eq(featureFlags.key, key))
.limit(1);
return flag[0]?.enabled ?? false;
});
// For checking multiple flags at once
export const getFlags = cache(async (keys: string[]): Promise<Record<string, boolean>> => {
const rows = await db
.select()
.from(featureFlags)
.where(sql`${featureFlags.key} = ANY(${keys})`);
const result: Record<string, boolean> = {};
for (const key of keys) {
result[key] = rows.find(r => r.key === key)?.enabled ?? false;
}
return result;
});Using React's `cache()` here is important — it deduplicates database calls within a single request. If five different Server Components on the same page all call `getFlag('newDashboard')`, you get one database query, not five.
Per-User and Per-Org Flags
Global on/off is useful, but the real killer feature is percentage rollouts and targeting. Ship the new checkout to 10% of users, see if the conversion rate drops, then gradually roll out to everyone. Or give beta testers early access while everyone else sees the old thing.
You can build basic targeting with just a few extra columns:
// Extended schema for user-level targeting
export const featureFlags = pgTable('feature_flags', {
key: text('key').primaryKey(),
enabled: boolean('enabled').notNull().default(false),
rolloutPercentage: integer('rollout_percentage').default(100), // 0-100
allowedUserIds: text('allowed_user_ids').array().default([]),
allowedOrgIds: text('allowed_org_ids').array().default([]),
description: text('description'),
updatedAt: timestamp('updated_at').defaultNow(),
});
// lib/flags.ts — evaluate flag for a specific user
export async function getFlagForUser(
key: string,
userId: string,
orgId?: string
): Promise<boolean> {
const flag = await db
.select()
.from(featureFlags)
.where(eq(featureFlags.key, key))
.limit(1);
if (!flag[0] || !flag[0].enabled) return false;
const { allowedUserIds, allowedOrgIds, rolloutPercentage } = flag[0];
// Explicit user or org override
if (allowedUserIds?.includes(userId)) return true;
if (orgId && allowedOrgIds?.includes(orgId)) return true;
// Percentage rollout — deterministic based on userId so the same
// user always gets the same experience
if (rolloutPercentage !== null && rolloutPercentage < 100) {
const hash = hashUserId(userId);
return hash % 100 < rolloutPercentage;
}
return true;
}
// Simple deterministic hash — not cryptographic, just consistent
function hashUserId(userId: string): number {
let hash = 0;
for (let i = 0; i < userId.length; i++) {
hash = ((hash << 5) - hash) + userId.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}The hash-based rollout is key: the same user always gets the same result. Nothing is worse than a feature that randomly appears and disappears for the same person because they reload the page.
Using Flags in Server Components and Middleware
In the App Router, you'll mostly evaluate flags in Server Components, which is exactly where you want them — no client-side flicker, no layout shift.
// app/dashboard/page.tsx
import { auth } from '@/lib/auth';
import { getFlagForUser } from '@/lib/flags';
import { NewDashboard } from './_components/new-dashboard';
import { OldDashboard } from './_components/old-dashboard';
export default async function DashboardPage() {
const session = await auth();
if (!session) redirect('/login');
const showNewDashboard = await getFlagForUser(
'new_dashboard',
session.user.id,
session.user.orgId
);
return showNewDashboard ? <NewDashboard /> : <OldDashboard />;
}For route-level access (like redirecting users to a beta feature), do it in middleware so you're not loading the page only to redirect:
// middleware.ts
import { NextRequest, NextResponse } from 'next/server';
export async function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Only check flags for specific paths to avoid querying on every request
if (pathname.startsWith('/checkout-v2')) {
const userId = request.cookies.get('user_id')?.value;
if (!userId) return NextResponse.redirect(new URL('/checkout', request.url));
// Edge-compatible flag check — keep this fast!
// For middleware, use a lightweight check (Redis, KV, or a simple API call)
const flagEnabled = await checkFlagEdge('beta_checkout', userId);
if (!flagEnabled) {
return NextResponse.redirect(new URL('/checkout', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/checkout-v2/:path*'],
};One thing to watch: middleware runs on the Edge runtime, which means no direct Postgres connection. If you need flags in middleware, either use a KV store (Vercel KV, Upstash Redis) or call a Route Handler that does the database lookup. The extra latency is usually worth the clean separation.
Caching Flags So You're Not Hammering Your Database
One database query per request per flag adds up fast. React's `cache()` handles deduplication within a request, but you probably want something that persists across requests too.
The easiest approach: Next.js `unstable_cache` with a short TTL.
import { unstable_cache } from 'next/cache';
import { db } from '@/db';
import { featureFlags } from '@/db/schema';
// Cache all flags for 60 seconds, tag them so we can invalidate on demand
export const getAllFlags = unstable_cache(
async () => {
const flags = await db.select().from(featureFlags);
return Object.fromEntries(flags.map(f => [f.key, f]));
},
['feature-flags'],
{
revalidate: 60, // seconds
tags: ['feature-flags'],
}
);
// When you update a flag in your admin panel, call this:
import { revalidateTag } from 'next/cache';
export async function updateFlag(key: string, enabled: boolean) {
await db
.update(featureFlags)
.set({ enabled, updatedAt: new Date() })
.where(eq(featureFlags.key, key));
// Invalidate the cache immediately
revalidateTag('feature-flags');
}Now flag changes propagate to all users within 60 seconds at worst, or instantly if you call `revalidateTag` from your admin panel update handler. That's a pretty solid setup without Redis or any external service.
Building a Tiny Admin Panel for Flags
Storing flags in the database only helps if you can actually update them without running SQL in production. You need a UI. It doesn't have to be fancy — a table with toggle switches is enough.
// app/admin/flags/page.tsx
import { db } from '@/db';
import { featureFlags } from '@/db/schema';
import { FlagToggle } from './_components/flag-toggle';
import { requireAdmin } from '@/lib/auth';
export default async function FlagsAdminPage() {
await requireAdmin();
const flags = await db.select().from(featureFlags).orderBy(featureFlags.key);
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-2xl font-bold mb-6">Feature Flags</h1>
<div className="space-y-3">
{flags.map(flag => (
<div key={flag.key} className="flex items-center justify-between p-4 border rounded-lg">
<div>
<p className="font-mono text-sm font-medium">{flag.key}</p>
{flag.description && (
<p className="text-sm text-gray-500 mt-1">{flag.description}</p>
)}
</div>
<FlagToggle flagKey={flag.key} enabled={flag.enabled} />
</div>
))}
</div>
</div>
);
}
// app/admin/flags/_components/flag-toggle.tsx
'use client';
import { useTransition } from 'react';
import { toggleFlag } from '../actions';
export function FlagToggle({ flagKey, enabled }: { flagKey: string; enabled: boolean }) {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => startTransition(() => toggleFlag(flagKey, !enabled))}
disabled={isPending}
className={`relative w-12 h-6 rounded-full transition-colors ${
enabled ? 'bg-green-500' : 'bg-gray-300'
} ${isPending ? 'opacity-50' : ''}`}
>
<span
className={`absolute top-1 w-4 h-4 bg-white rounded-full transition-transform ${
enabled ? 'translate-x-7' : 'translate-x-1'
}`}
/>
</button>
);
}Protect that admin route properly. You do not want a disgruntled user finding `/admin/flags` and turning off your payment flow.
When to Use a Third-Party Tool Instead
The database approach above covers 90% of use cases. But there are situations where a dedicated tool like Unleash (self-hosted, free), LaunchDarkly, or Statsig makes more sense:
- You need sophisticated targeting rules (geo, device type, custom attributes) and don't want to build that logic yourself
- You want a proper audit trail of every flag change with who changed it and when
- You need flag evaluations in mobile apps, backend services, and web all in sync
- Your team does A/B testing with statistical significance tracking baked in
- You're running experiments at scale and need variance reduction, guardrail metrics, etc.
For most indie projects and early-stage SaaS products? The database approach is genuinely enough. We use it across the templates at peal.dev and it's never been the bottleneck. Save the $500/month until you actually need the extra features.
The Operational Side: How to Actually Use Flags Well
Having flags is half the battle. The other half is discipline around how you use them. Some things we've learned:
- Give every flag a description. Future you will have no idea what 'new_nav_v3_final_FINAL' means in six months.
- Delete flags after the feature fully ships. Flags are technical debt. A codebase with 50 old flags that all evaluate to true is a mess to maintain.
- Don't put flag checks deep in utility functions where they're invisible. Keep them at the component or page level where they're obvious.
- Test both paths in CI. Flag off should be as tested as flag on. Don't let the old path rot.
- Log when flag state is evaluated in prod. If something breaks, you want to know 'user X had flag Y = false at the time of the error'.
The goal isn't to have feature flags forever. It's to ship code continuously and control the rollout. Once a feature is stable and fully rolled out, the flag should go in the trash.
One more thing: don't use flags as a substitute for actually finishing features. We've seen codebases with features that have been 'in beta behind a flag' for 18 months. At that point the flag isn't protecting users from an incomplete feature — it's protecting the team from doing the hard work of shipping it properly.
The whole point of this system is to merge code every day, get feedback fast, and ship with confidence. If you're doing it right, flags should be short-lived, your main branch should always be deployable, and the days of 'the feature branch' as a concept should be mostly behind you. That alone is worth the hour it takes to set this up.
