// LOADING
// LOADING
// LOADING_ARTICLE
Deploying code and releasing a feature are two different events. Conflating them is the root cause of many late-night incidents. Feature flags let you ship code to production continuously while controlling exactly who sees what, when. This post covers the mechanics — how flags work, how to implement a lightweight system, and how to run gradual rollouts safely.
A feature flag is a conditional branch in your application code whose value comes from outside the codebase — typically a configuration store, an environment variable, or a remote service. The simplest possible flag:
tsconst ENABLE_NEW_DASHBOARD = process.env.NEXT_PUBLIC_ENABLE_NEW_DASHBOARD === 'true'; export function Dashboard() { if (ENABLE_NEW_DASHBOARD) { return <NewDashboard />; } return <LegacyDashboard />; }
This is useful for environment gating (staging only, production off) but offers no runtime control. For that you need the flag value to come from a database or a remote config service that your application reads at request time.
For a small system, a MongoDB collection or a simple JSON document in S3 is sufficient. Each flag document looks like:
json{ "key": "new-checkout-flow", "enabled": true, "rollout": 20, "allowList": ["user_abc123", "user_def456"] }
rollout is a percentage (0–100). allowList overrides rollout for specific users — useful for QA and internal testers.
In the App Router, evaluate flags in Server Components or Route Handlers so the flag logic never ships to the client:
ts// lib/flags.ts import { cache } from 'react'; type Flag = { key: string; enabled: boolean; rollout: number; allowList: string[] }; const getFlags = cache(async (): Promise<Flag[]> => { // Replace with your DB call const res = await fetch('https://internal-config/flags', { next: { revalidate: 60 } }); return res.json(); }); export async function isEnabled(key: string, userId?: string): Promise<boolean> { const flags = await getFlags(); const flag = flags.find((f) => f.key === key); if (!flag || !flag.enabled) return false; if (userId && flag.allowList.includes(userId)) return true; if (flag.rollout >= 100) return true; if (flag.rollout <= 0) return false; // Stable bucketing: hash userId to a 0-99 bucket const bucket = hashToBucket(userId ?? ''); return bucket < flag.rollout; } function hashToBucket(input: string): number { let h = 0; for (let i = 0; i < input.length; i++) h = (Math.imul(31, h) + input.charCodeAt(i)) | 0; return Math.abs(h) % 100; }
The cache() wrapper from React deduplicates the flag fetch within a single request, so multiple components evaluating flags in the same render do not cause redundant DB round-trips.
tsx// app/checkout/page.tsx import { isEnabled } from '@/lib/flags'; import { auth } from '@/lib/auth'; export default async function CheckoutPage() { const session = await auth(); const useNewFlow = await isEnabled('new-checkout-flow', session?.userId); return useNewFlow ? <NewCheckout /> : <LegacyCheckout />; }
A gradual rollout increases rollout from 0 to 100 over time, watching error rates at each step. A typical schedule:
The stable hashing in hashToBucket ensures that a user who sees the new experience at 25% continues to see it at 50%. Without stable bucketing, users flicker between old and new as the percentage changes — a poor experience and a debugging nightmare.
A kill switch is a flag you flip in an emergency to disable a broken feature without a deploy. The flag system described above supports this natively: set enabled: false in the config store and the feature disappears within the next cache TTL (60 seconds in the example above).
For truly critical features — payment flows, authentication — consider a TTL of 10–15 seconds at the cost of slightly higher DB load. This is how large services maintain "rollback without a deploy" as a standard operating procedure.
A home-grown system handles most use cases, but managed services (LaunchDarkly, Unleash, Flagsmith) add:
For a team shipping multiple experiments simultaneously, the operational overhead of rolling your own starts to outweigh the cost of a managed service. For a solo developer or small team, a simple DB-backed system is entirely sufficient.
Flags that live forever become unmaintained conditionals that confuse new engineers. Add a expiresAt field to every flag and enforce cleanup:
This connects directly to how you structure your codebase for maintainability — see how I structure large Next.js + Node.js projects for broader conventions that pair well with this approach.
Feature flags break the hard coupling between deploying code and releasing features. A lightweight implementation in Next.js — a flags collection, a cached evaluation function, and stable user bucketing — covers the vast majority of use cases. Pair this with a CI/CD pipeline (see CI/CD for Next.js with GitHub Actions) and you get a deploy workflow where every merge to main goes to production safely, regardless of whether the feature is ready to show users. Browse projects to see these practices in a real production context.