How to Implement Feature Flags in a Next.js App Without a Third-Party Service

How to Implement Feature Flags in a Next.js App Without a Third-Party Service

by | Sep 23, 2026 | Uncategorized | 0 comments

Every team that ships fast eventually needs feature flags. You want to merge unfinished work into main, turn a risky feature on for 5% of users, or give one enterprise customer early access without a separate branch. The usual advice is to sign up for LaunchDarkly, Statsig or ConfigCat and start paying per monthly active user.

For a small team, that bill is hard to justify when the actual logic behind a flag is a boolean, a percentage and a list of user IDs. This tutorial shows you how to implement feature flags in Next.js yourself, with full self-hosted control: environment variables for build-time toggles, a database table for runtime toggles, deterministic percentage rollouts, per-user targeting, and a React context so client components can read the same values the server used.

Everything below targets the App Router (Next.js 15 and 16), uses Server Components by default, and works on Vercel, Docker, or a plain Node server.

What you are going to build

  • A typed flag registry so a typo in a flag name is a compile error, not a silent false.
  • Two layers of control: static env flags (kill switches, per-environment defaults) and dynamic DB flags (change without redeploying).
  • A pure evaluation function with allow lists, block lists and percentage rollouts.
  • A cached loader so you do not hit the database on every request.
  • A React context provider so client components read flags without a network call and without hydration flicker.
  • Middleware that gates entire routes and assigns a stable anonymous ID.
  • A tiny admin action to flip flags at runtime, protected by your existing auth.
toggle switches

Build vs buy: an honest comparison

Criteria Self-hosted (this guide) Hosted SaaS (LaunchDarkly, Statsig, ConfigCat)
Cost Zero, one table in your existing database Per seat or per MAU, grows with traffic
Setup time Half a day An hour
Latency In-process cache, no external hop Edge CDN or SDK cache, usually fine
Data residency User IDs never leave your infrastructure Targeting attributes sent to a vendor
Audit log, approvals, scheduling You build it Included
Experimentation and stats engine Not included Included
Non-technical users flipping flags Needs a small internal admin page Polished dashboard

Rule of thumb: if you mainly need release toggles and gradual rollouts, build it. If you need statistical significance calculations on A/B tests and a dashboard for the marketing team, buy it.

Architecture in one paragraph

A flag is evaluated on the server, once per request, inside your root layout. The result is a plain object of booleans that is passed to a client context provider and rendered into the HTML. Server Components read flags directly from the loader. Client Components read them from context. Because both read the same evaluated object, the server render and the client hydration always agree, which is what kills the classic flag flicker.

toggle switches

Step 1: Environment variable flags (the 10 minute version)

Start here. Not every flag needs a database. Environment variables are perfect for kill switches, per-environment defaults, and features that are simply not ready in production.

// lib/flags/env.ts
export const envFlags = {
  'new-checkout': process.env.FLAG_NEW_CHECKOUT === 'true',
  'ai-search': process.env.FLAG_AI_SEARCH === 'true',
  'billing-v2': process.env.FLAG_BILLING_V2 === 'true',
} as const;

export type FlagKey = keyof typeof envFlags;

Two things to keep in mind:

  1. Variables without the NEXT_PUBLIC_ prefix are server only. That is what you want. Never expose a flag that hides an unreleased pricing model in the client bundle.
  2. Values are inlined at build time for statically rendered pages. Changing one means a redeploy. That is the whole reason we add a database in step 2.

Step 2: The database table

One table is enough. Here it is in PostgreSQL, but the same shape works in MySQL, SQLite or Turso. The team at aurorascharff.no reached a similar conclusion.

create table feature_flags (
  key                 text primary key,
  description         text,
  enabled             boolean not null default false,
  rollout_percentage  smallint not null default 0 check (rollout_percentage between 0 and 100),
  allowed_user_ids    text[] not null default '{}',
  blocked_user_ids    text[] not null default '{}',
  allowed_plans       text[] not null default '{}',
  updated_at          timestamptz not null default now(),
  updated_by          text
);

insert into feature_flags (key, description, enabled, rollout_percentage)
values ('new-checkout', 'Rewritten checkout funnel', true, 10);

Column meanings:

Column Role
enabled Master switch. If false, only the allow list can see the feature.
rollout_percentage 0 to 100. Deterministic bucketing, not a coin flip.
allowed_user_ids Always on: internal staff, beta customers, that one demo account.
blocked_user_ids Always off, wins over everything. Your escape hatch when a customer complains.
allowed_plans Segment targeting by subscription tier, role or country.

Step 3: The evaluation engine

The most important detail of a gradual rollout is stickiness: user 42 must land in the same bucket on every request, on every server instance, forever. Do not use Math.random(). Hash the flag key together with the user ID.

// lib/flags/hash.ts
// FNV-1a: fast, dependency free, well distributed enough for bucketing.
export function bucketOf(flagKey: string, id: string): number {
  const input = flagKey + ':' + id;
  let hash = 2166136261;
  for (let i = 0; i < input.length; i++) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 16777619);
  }
  return Math.abs(hash) % 100;
}

Hashing with the flag key included means a user who is in the unlucky 90% for one flag is not automatically excluded from every other rollout. Reference: https://vercel.com.

// lib/flags/evaluate.ts
import { bucketOf } from './hash';

export type FlagRecord = {
  key: string;
  enabled: boolean;
  rolloutPercentage: number;
  allowedUserIds: string[];
  blockedUserIds: string[];
  allowedPlans: string[];
};

export type EvalContext = {
  userId?: string;
  anonymousId?: string;
  plan?: 'free' | 'pro' | 'enterprise';
  country?: string;
  isInternal?: boolean;
};

export function evaluate(flag: FlagRecord | undefined, ctx: EvalContext): boolean {
  if (!flag) return false;

  const id = ctx.userId ?? ctx.anonymousId;

  // 1. Block list always wins.
  if (id && flag.blockedUserIds.includes(id)) return false;

  // 2. Allow list bypasses the master switch (useful for internal QA in prod).
  if (id && flag.allowedUserIds.includes(id)) return true;

  // 3. Master switch.
  if (!flag.enabled) return false;

  // 4. Segment targeting.
  if (flag.allowedPlans.length > 0) {
    if (!ctx.plan || !flag.allowedPlans.includes(ctx.plan)) return false;
  }

  // 5. Percentage rollout.
  if (flag.rolloutPercentage >= 100) return true;
  if (flag.rolloutPercentage <= 0) return false;
  if (!id) return false; // no stable identity, stay conservative

  return bucketOf(flag.key, id) < flag.rolloutPercentage;
}

This function is pure, which means you can unit test your rollout logic in milliseconds. A quick sanity test: generate 10,000 fake IDs at 25% and assert that between 23% and 27% pass.

toggle switches

Step 4: Loading flags without hammering the database

Reading a table on every request is wasteful. Wrap the query in a cache with a tag so an admin toggle can invalidate it instantly.

// lib/flags/store.ts
import 'server-only';
import { unstable_cache, revalidateTag } from 'next/cache';
import { sql } from '@/lib/db';
import { envFlags } from './env';
import type { FlagRecord } from './evaluate';

export const FLAGS_TAG = 'feature-flags';

export const getFlagRecords = unstable_cache(
  async (): Promise<Record<string, FlagRecord>> => {
    const rows = await sql<any[]>`
      select key, enabled, rollout_percentage, allowed_user_ids,
             blocked_user_ids, allowed_plans
      from feature_flags
    `;

    const map: Record<string, FlagRecord> = {};
    for (const r of rows) {
      map[r.key] = {
        key: r.key,
        // An env kill switch set to false overrides the database.
        enabled: r.enabled && envFlags[r.key as keyof typeof envFlags] !== false,
        rolloutPercentage: r.rollout_percentage,
        allowedUserIds: r.allowed_user_ids ?? [],
        blockedUserIds: r.blocked_user_ids ?? [],
        allowedPlans: r.allowed_plans ?? [],
      };
    }
    return map;
  },
  ['feature-flags-v1'],
  { tags: [FLAGS_TAG], revalidate: 60 }
);

export function invalidateFlags() {
  revalidateTag(FLAGS_TAG);
}

The revalidate: 60 is a safety net in case a tag invalidation is missed on a multi-instance deployment. If you are already using the newer 'use cache' directive with cacheTag() and cacheLife(), the same pattern applies, just swap the wrapper.

Resolving all flags for the current request

// lib/flags/index.ts
import 'server-only';
import { cookies } from 'next/headers';
import { cache } from 'react';
import { getFlagRecords } from './store';
import { evaluate, type EvalContext } from './evaluate';
import { getSession } from '@/lib/auth';

// React cache() dedupes this across a single render pass.
export const getFlags = cache(async (): Promise<Record<string, boolean>> => {
  const [records, session, cookieStore] = await Promise.all([
    getFlagRecords(),
    getSession(),
    cookies(),
  ]);

  const ctx: EvalContext = {
    userId: session?.user?.id,
    anonymousId: cookieStore.get('aid')?.value,
    plan: session?.user?.plan,
    isInternal: session?.user?.email?.endsWith('@boxsoftware.net'),
  };

  const result: Record<string, boolean> = {};
  for (const key of Object.keys(records)) {
    result[key] = evaluate(records[key], ctx);
  }
  return result;
});

export async function isEnabled(key: string): Promise<boolean> {
  const flags = await getFlags();
  return flags[key] ?? false;
}

Using a flag in a Server Component is now a one liner:

// app/checkout/page.tsx
import { isEnabled } from '@/lib/flags';
import CheckoutV1 from './checkout-v1';
import CheckoutV2 from './checkout-v2';

export default async function CheckoutPage() {
  const newCheckout = await isEnabled('new-checkout');
  return newCheckout ? <CheckoutV2 /> : <CheckoutV1 />;
}

Step 5: React context for client components

Client Components cannot await the database. Resolve everything once in the root layout and hand the object down.

// components/flags-provider.tsx
'use client';
import { createContext, useContext, type ReactNode } from 'react';

const FlagsContext = createContext<Record<string, boolean>>({});

export function FlagsProvider({
  flags,
  children,
}: {
  flags: Record<string, boolean>;
  children: ReactNode;
}) {
  return <FlagsContext.Provider value={flags}>{children}</FlagsContext.Provider>;
}

export function useFlag(key: string): boolean {
  return useContext(FlagsContext)[key] ?? false;
}

export function useFlags(): Record<string, boolean> {
  return useContext(FlagsContext);
}
// app/layout.tsx
import { getFlags } from '@/lib/flags';
import { FlagsProvider } from '@/components/flags-provider';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const flags = await getFlags();

  return (
    <html lang='en'>
      <body>
        <FlagsProvider flags={flags}>{children}</FlagsProvider>
      </body>
    </html>
  );
}
// components/nav.tsx
'use client';
import { useFlag } from '@/components/flags-provider';

export function Nav() {
  const aiSearch = useFlag('ai-search');
  return (
    <nav>
      <a href='/dashboard'>Dashboard</a>
      {aiSearch && <a href='/search'>AI Search</a>}
    </nav>
  );
}

Important: only ship flags that are safe to expose. If a flag name leaks a confidential roadmap item, filter the object before passing it to the provider, or name your flags neutrally (experiment-a7 instead of acquisition-of-competitor-banner).

Step 6: Middleware for anonymous IDs and route gating

Logged out visitors still need a stable identity, otherwise a 20% rollout will look different on every page load. Assign a cookie once.

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

export function middleware(req: NextRequest) {
  const res = NextResponse.next();

  if (!req.cookies.get('aid')) {
    res.cookies.set('aid', crypto.randomUUID(), {
      httpOnly: true,
      sameSite: 'lax',
      secure: process.env.NODE_ENV === 'production',
      maxAge: 60 * 60 * 24 * 365,
      path: '/',
    });
  }

  return res;
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};

To hide a whole route behind a flag, keep the middleware lightweight and do the redirect in the page or layout instead, since middleware runs before your database cache and adds latency to every request. A notFound() call in a Server Component is usually the cleaner answer:

// app/(beta)/labs/layout.tsx
import { notFound } from 'next/navigation';
import { isEnabled } from '@/lib/flags';

export default async function LabsLayout({ children }: { children: React.ReactNode }) {
  if (!(await isEnabled('labs'))) notFound();
  return <>{children}</>;
}
toggle switches

Step 7: Flipping flags at runtime

A Server Action plus a 40 line admin page gives you the part that actually replaces the SaaS dashboard.

// app/admin/flags/actions.ts
'use server';
import { sql } from '@/lib/db';
import { requireAdmin } from '@/lib/auth';
import { invalidateFlags } from '@/lib/flags/store';
import { revalidatePath } from 'next/cache';

export async function setRollout(key: string, percentage: number) {
  const admin = await requireAdmin();
  const pct = Math.min(100, Math.max(0, Math.round(percentage)));

  await sql`
    update feature_flags
    set rollout_percentage = ${pct}, updated_at = now(), updated_by = ${admin.email}
    where key = ${key}
  `;

  invalidateFlags();
  revalidatePath('/admin/flags');
}

export async function killSwitch(key: string) {
  const admin = await requireAdmin();
  await sql`
    update feature_flags
    set enabled = false, rollout_percentage = 0, updated_at = now(), updated_by = ${admin.email}
    where key = ${key}
  `;
  invalidateFlags();
  revalidatePath('/admin/flags');
}

Add an feature_flag_audit table with a row per change if you want the compliance story too. It is a single insert inside the same transaction.

Step 8: Local overrides for developers and QA

Testing a 5% rollout is painful unless you can force a value. Allow an override cookie outside production:

// inside getFlags(), before returning
if (process.env.NODE_ENV !== 'production' || ctx.isInternal) {
  const override = cookieStore.get('ff_override')?.value; // e.g. 'new-checkout:1,ai-search:0'
  if (override) {
    for (const pair of override.split(',')) {
      const [k, v] = pair.split(':');
      if (k) result[k] = v === '1';
    }
  }
}

Then a bookmarklet or a small debug panel sets that cookie. Your QA team will thank you.

Caching gotchas you must know about

  • Reading cookies or headers makes a route dynamic. Any page under a layout that calls getFlags() with cookie access opts out of static rendering. If you rely on static pages, evaluate flags only in the subtree that needs them and wrap it in <Suspense>, or keep marketing pages on env flags only.
  • Partial Prerendering is your friend. Put the flag dependent part inside a Suspense boundary so the static shell is still served from the edge while the personalized branch streams in.
  • Do not put user specific flags in the full route cache. Cache the flag definitions, never the evaluated result for a specific user.
  • Set a short revalidate window. Sixty seconds is a good compromise between database load and how fast a kill switch propagates across instances.
  • Client bundles are public. Anything reaching FlagsProvider is visible in the HTML source.
toggle switches

Flag hygiene: the part everyone skips

Self-hosted flags rot faster than paid ones because nothing nags you. Adopt three rules:

  1. Every flag gets an owner and an expiry date in the description column. A release toggle that is still around after 60 days is technical debt.
  2. Type the keys. A union type for FlagKey makes removal a compiler-guided refactor.
  3. Delete both sides. When a flag hits 100% and stays there for two weeks, remove the flag, the old branch and the database row in one pull request.

A weekly CI job that lists flags older than 60 days and opens an issue takes twenty minutes to write and saves you from a codebase with 80 dead toggles.

When you should not build this

Be pragmatic. Reach for the Flags SDK, OpenFeature with a provider, self-hosted Unleash, or a paid platform when:

  • You need real experimentation with metrics pipelines, sequential testing and guardrail metrics.
  • Non-engineers need to change targeting rules daily and expect approval workflows.
  • You have multiple applications in different languages that must share one flag source of truth.
  • You need audited change history for a compliance framework and do not want to build it.

For everything else, the roughly 200 lines above cover the 90% case with zero recurring cost and no vendor in your request path.

FAQ

Do feature flags slow down a Next.js app?

Not measurably if the flag definitions are cached in process. The evaluation itself is a hash and a few array lookups, well under a millisecond. The real cost is opting a route out of static rendering, which is why you should scope cookie access to the components that need it.

How do I avoid flicker when a client component reads a flag?

Evaluate on the server and pass the result through React context, as shown in step 5. The server rendered HTML and the first client render use the exact same values, so there is nothing to flicker. Fetching flags from a client side effect is what causes the flash.

Can I use these flags in middleware or on the Edge runtime?

Yes, but only with a data source reachable from the Edge, such as an HTTP based Postgres driver, Redis over REST, or Edge Config. Keep it to route level decisions and remember that every millisecond there is added to every request.

How do I make a percentage rollout consistent for the same user?

Hash a stable identifier together with the flag key and compare the resulting 0 to 99 bucket against the percentage. Never use random numbers, and never use the session ID, since it changes on every login.

Should feature flags live in environment variables or in a database?

Both. Use environment variables for build time defaults and hard kill switches per environment. Use the database when you need to change behaviour without a deploy, target specific users, or ramp a rollout. Let the env value override the database so an incident can be stopped even if the database is unreachable. The piece Feature flags for JavaScript makes a good next read.

Is this compatible with the Vercel Flags SDK or OpenFeature?

Yes. Keep evaluate() pure and you can later expose it as a custom OpenFeature provider, or wrap each flag with the Flags SDK flag() helper while your database remains the source of truth. Migrating is a few hours of work, which is exactly why starting simple is low risk.

How many flags is too many?

There is no hard limit, but a codebase with more than roughly 20 live flags at once usually has a cleanup problem, not a tooling problem. Track age and delete aggressively.

Need a hand?

At Box Software we build and maintain Next.js products for teams that care about shipping speed and infrastructure costs. If you want this flag system implemented, tested and wired into your admin panel, or you are weighing a migration away from a per-MAU billing plan, get in touch and we will scope it with you.