50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 19, 2026·8 min read

Feature Flags in Next.js — Shipping Safely Without Feature Branches

Stop merging 3-week-old branches at midnight. Here's how we use feature flags in Next.js to ship continuously and sleep better.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Feature Flags in Next.js — Shipping Safely Without Feature Branches

We used to have a branch called `feature/new-dashboard` that lived for six weeks. By the time we tried to merge it, main had moved so far that the diff looked like a completely different codebase. Three hours of conflict resolution, a botched deploy, and one very silent Slack channel later — we decided there had to be a better way.

Feature flags (or feature toggles, if you want to sound fancy at a conference) let you merge code continuously without actually releasing it. The code ships to production, but it's hidden behind a condition. You flip a switch when you're ready. No mega-merges, no "this only works on my branch" nonsense, no 2am rollbacks because the feature wasn't actually done.

This isn't a new idea — it's been in the toolbox since the trunk-based development days. But Next.js gives you a few specific places where flags slot in really nicely, and there are some gotchas that will bite you if you don't think it through. Let's go through the whole thing.

The Simplest Possible Flag System (That Actually Works)

You don't need LaunchDarkly on day one. For most indie projects and early-stage SaaS apps, environment variables are enough to get started. Here's what a minimal flag setup looks like:

// lib/flags.ts
const flags = {
  newDashboard: process.env.NEXT_PUBLIC_FLAG_NEW_DASHBOARD === 'true',
  billingV2: process.env.FLAG_BILLING_V2 === 'true', // server-only
  betaOnboarding: process.env.NEXT_PUBLIC_FLAG_BETA_ONBOARDING === 'true',
} as const;

export type FlagName = keyof typeof flags;

export function isEnabled(flag: FlagName): boolean {
  return flags[flag] ?? false;
}

Notice the two prefixes. `NEXT_PUBLIC_` flags are safe to expose to the browser — think UI changes, new pages, beta features. Flags without that prefix stay server-side only — use those for anything touching billing logic, data processing, or things you really don't want users poking at in devtools.

Then in your component or page, it's just:

// app/dashboard/page.tsx
import { isEnabled } from '@/lib/flags';
import { NewDashboard } from '@/components/NewDashboard';
import { OldDashboard } from '@/components/OldDashboard';

export default function DashboardPage() {
  if (isEnabled('newDashboard')) {
    return <NewDashboard />;
  }
  return <OldDashboard />;
}

Dead simple. You ship both components, the old one stays active, and when you're confident the new one works you flip the env var and redeploy. No merge conflict. No ceremony.

Graduating to Per-User Flags

Environment variable flags are binary — everyone gets the feature or nobody does. That's fine for flipping a feature on for production in one shot, but it's not great for rolling out to 10% of users, or letting beta testers in early, or giving your paying customers a sneak peek.

For per-user flags, you need to involve the session. Here's a pattern we like — store flag overrides in the user's database record, and merge them with the global defaults at request time:

// lib/flags.ts (upgraded)
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

type FlagConfig = {
  defaultValue: boolean;
  serverOnly?: boolean;
};

const FLAG_CONFIG: Record<string, FlagConfig> = {
  newDashboard: { defaultValue: false },
  billingV2: { defaultValue: false, serverOnly: true },
  betaOnboarding: { defaultValue: false },
};

export async function getFlags() {
  const session = await auth();

  // Base flags from environment
  const resolved: Record<string, boolean> = {};
  for (const [key, config] of Object.entries(FLAG_CONFIG)) {
    const envKey = `FLAG_${key.toUpperCase()}`;
    resolved[key] = process.env[envKey] === 'true' ?? config.defaultValue;
  }

  // Merge per-user overrides if logged in
  if (session?.user?.id) {
    const user = await db.user.findUnique({
      where: { id: session.user.id },
      select: { featureFlags: true },
    });

    if (user?.featureFlags) {
      const overrides = user.featureFlags as Record<string, boolean>;
      for (const [key, value] of Object.entries(overrides)) {
        if (key in resolved) {
          resolved[key] = value;
        }
      }
    }
  }

  return resolved;
}

This lets you enroll specific users into a beta by setting their `featureFlags` column in the database. The global env var acts as the master switch — flip it to `true` and everyone gets it. Individual overrides let you do gradual rollouts or give specific users early access.

Store feature flags as a JSON column on your users table. It's flexible, queryable, and you can add new flags without a schema migration.

Middleware Flags for Route-Level Control

Sometimes you don't want a disabled feature to render at all — you want to redirect users away from the route entirely. Next.js middleware is perfect for this because it runs at the edge before any page code executes.

// middleware.ts
import { NextRequest, NextResponse } from 'next/server';

const ROUTE_FLAGS: Record<string, string> = {
  '/dashboard/new': 'NEXT_PUBLIC_FLAG_NEW_DASHBOARD',
  '/onboarding/v2': 'NEXT_PUBLIC_FLAG_BETA_ONBOARDING',
};

export function middleware(request: NextRequest) {
  const pathname = request.nextUrl.pathname;

  for (const [route, envVar] of Object.entries(ROUTE_FLAGS)) {
    if (pathname.startsWith(route)) {
      const flagEnabled = process.env[envVar] === 'true';
      if (!flagEnabled) {
        // Redirect to the stable version
        const fallback = route.replace('/new', '').replace('/v2', '');
        return NextResponse.redirect(new URL(fallback, request.url));
      }
    }
  }

  return NextResponse.next();
}

export const config = {
  matcher: ['/dashboard/:path*', '/onboarding/:path*'],
};

One caveat with middleware flags: at the edge, you only have access to environment variables and cookies — no database calls. So for per-user flags at the middleware level, you need to stash the user's enabled flags in a signed cookie (set it when they log in) and read it here.

The Caching Problem Nobody Warns You About

This is the one that got us. We had a feature flag controlling a section of the homepage. Flipped the flag on, redeployed — but half our users were still seeing the old version for 20 minutes. The culprit: aggressive caching.

If you're using React Server Components and your page is cached (either via the Next.js full-route cache or a CDN), flipping a flag won't immediately change what users see. You need to account for this.

  • If a page uses feature flags and needs to be fresh, make sure it opts out of caching or has a short revalidation time.
  • For server components, calling `cookies()` or `headers()` inside the component automatically makes it dynamic — which is fine and expected for flagged content.
  • For CDN-cached pages, tag them with a cache key that includes the flag state, or just don't cache flagged pages.
  • If you're reading flags from the database per-user, those requests are already dynamic by nature. Just don't accidentally cache the whole route.
// Force dynamic rendering for flagged pages
import { unstable_noStore as noStore } from 'next/cache';

export default async function FlaggedPage() {
  noStore(); // opt out of caching for this render
  
  const flags = await getFlags();
  
  return flags.newDashboard ? <NewDashboard /> : <OldDashboard />;
}

Alternatively, if you're on Next.js 14+ with the App Router, any server component that reads from `cookies()` is automatically dynamic. If your `getFlags()` function reads from a cookie or session, you're probably already fine — just be aware of what's happening under the hood.

When to Actually Pay for a Feature Flag Service

The environment variable + database approach takes you pretty far. But there are real situations where a dedicated service like LaunchDarkly, Unleash, GrowthBook, or Posthog's feature flags is worth it:

  • You need percentage-based rollouts (give 5% of users the new feature, gradually increase). This is annoying to build yourself.
  • You want targeting rules beyond just 'this specific user' — things like 'users in Germany' or 'users on a paid plan'.
  • You need flag changes to take effect instantly without a redeploy.
  • You want an audit log of who changed what flag and when (very useful when something breaks).
  • You're running A/B tests and want statistical significance calculations out of the box.

GrowthBook is worth mentioning specifically because it's open-source and self-hostable. If you're running your own infrastructure, you can deploy GrowthBook alongside your app and get proper feature flag management without paying per-seat fees. We've used it on a few projects and the Next.js SDK integration is straightforward.

For most indie projects though, the homegrown approach is fine until it isn't. Don't over-engineer before you have the pain.

Cleaning Up Dead Flags (Don't Skip This)

Feature flags are technical debt with an expiry date — you just have to actually set the expiry. We've seen codebases with flags from two years ago that nobody knows are still there. Is `billingV2` the current billing? Is there a V3 now? Nobody knows. The original developer left.

When you add a flag, add a comment with an expected removal date and a ticket to clean it up:

// lib/flags.ts
const FLAG_CONFIG = {
  // TODO: Remove by 2025-03-01 after full rollout (ticket: APP-234)
  newDashboard: { defaultValue: false },

  // Permanent flag — controls beta access for specific users
  betaAccess: { defaultValue: false, permanent: true },
} as const;

The cleanup process is: enable the flag globally → monitor for one sprint → remove the flag and the old code path. The old code path is the important bit. Don't just leave both implementations in the codebase with the flag permanently set to true. Delete the old one. That's the whole point.

A feature flag that's been permanently 'on' for 6 months isn't a feature flag anymore. It's just dead code waiting to confuse someone.

Putting It Together in a Real Flow

Here's how the actual workflow looks when you're doing this properly:

  • Add the flag definition to your flags config, defaulting to false.
  • Write your new code path behind the flag. The old path keeps working.
  • Merge to main and deploy. Nothing changes for users.
  • Enable the flag for your own user account and test in production.
  • Gradually enable for internal team, then beta users, then everyone.
  • Once you're confident, flip the global env var and redeploy.
  • After one stable sprint, delete the old code path and the flag definition.

The key insight: you're not shipping the feature when you merge. You're shipping the code. The feature ships when you flip the flag. These are separate decisions with separate risk profiles, and keeping them separate is what makes trunk-based development actually work.

If you're building on a peal.dev template, this pattern plugs in cleanly — the auth and database layers are already wired up, so adding a `featureFlags` column and the flag resolution logic is maybe 20 minutes of work.

The main thing is just starting. Pick one feature you're currently developing on a branch. Add a flag. Merge it to main today with the flag off. You'll immediately feel how different it is to work with your main branch always being deployable. Then you'll never want to go back to six-week feature branches.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders