Claude Code Boilerplate
FeaturesPricingBlogDocs
Get started →

Product

  • Features
  • Pricing
  • Skills

Compare

  • vs ShipFast
  • vs MakerKit
  • vs supastarter

Resources

  • Docs
  • Blog
  • Discord

Legal

  • License
  • Privacy Policy
  • Terms of Service
Claude Code Boilerplate

© 2026 Claude Code Boilerplate. All rights reserved.

← All posts

Feature flags in Next.js App Router with Drizzle ORM -- roll out features by user or plan

June 19, 2026
nextjsdrizzle-ormsaasneon-db

Feature flags in Next.js App Router with Drizzle ORM -- roll out features by user or plan

Shipping a feature to all your users at once is a bet. What if it breaks for a subset of browsers? What if the new onboarding flow converts worse for free-tier users? Feature flags let you answer those questions before you commit -- and pull back without a redeploy if something goes wrong.

This post shows you how to add database-backed feature flags to a Next.js SaaS using Drizzle ORM and Neon DB -- the same stack already wired up in the Claude Code boilerplate.

Why database-backed instead of environment variables

Environment variables work fine for a single flag, but they require a redeploy to change. A database flag flips instantly, works differently per user or plan tier, and you can build an admin UI on top of it without touching your .env.

The schema

Add a feature_flags table to your Drizzle schema:

// modules/flag/flag.schema.ts
import { pgTable, text, boolean, timestamp } from 'drizzle-orm/pg-core';
import { createId } from '@paralleldrive/cuid2';
 
export const featureFlagTable = pgTable('feature_flags', {
  id: text('id').primaryKey().$defaultFn(() => createId()),
  key: text('key').notNull().unique(),
  enabled: boolean('enabled').notNull().default(false),
  allowedPlans: text('allowed_plans').array().notNull().default([]),
  allowedUserIds: text('allowed_user_ids').array().notNull().default([]),
  createdAt: timestamp('created_at').notNull().defaultNow(),
});

Three columns do the heavy lifting:

  • enabled -- global kill switch; false means nobody sees the feature regardless of plan or user
  • allowedPlans -- list of plan names that can access it when enabled (e.g. ["pro", "enterprise"])
  • allowedUserIds -- list of specific user IDs for individual access, useful for beta testers

Run the migration after adding the schema:

npm run db:generate
npm run db:migrate

The service

Keep the evaluation logic in a service so routes and components stay thin:

// modules/flag/flag.service.ts
import { db } from '@/db/drizzle';
import { featureFlagTable } from './flag.schema';
import { eq } from 'drizzle-orm';
 
interface FlagContext {
  userId?: string;
  plan?: string;
}
 
export const flagService = {
  isEnabled: async (key: string, ctx: FlagContext = {}): Promise<boolean> => {
    const flag = await db.query.featureFlagTable.findFirst({
      where: eq(featureFlagTable.key, key),
    });
 
    if (!flag || !flag.enabled) return false;
 
    const hasUserList = flag.allowedUserIds.length > 0;
    const hasPlanList = flag.allowedPlans.length > 0;
 
    // No restrictions -- everyone with enabled=true gets it
    if (!hasUserList && !hasPlanList) return true;
 
    // User-level override takes priority
    if (ctx.userId && flag.allowedUserIds.includes(ctx.userId)) return true;
 
    // Plan-level access
    if (ctx.plan && flag.allowedPlans.includes(ctx.plan)) return true;
 
    return false;
  },
};

The evaluation rules are straightforward:

  1. Flag disabled globally -- nobody sees it
  2. Flag enabled, no lists -- everyone sees it
  3. Flag enabled, lists present -- must match a user ID or a plan name

Using the flag in a server component

Fetch the current user, then ask the flag service before rendering the gated section:

// app/(main)/dashboard/page.tsx
import { flagService } from '@/modules/flag/flag.service';
import { getUserFromRequest } from '@/lib/auth';
import { cookies } from 'next/headers';
import { NewDashboard } from '@/components/NewDashboard';
import { OldDashboard } from '@/components/OldDashboard';
 
export default async function DashboardPage() {
  const cookieStore = await cookies();
  const token = cookieStore.get('token')?.value ?? '';
  const user = getUserFromRequest({ headers: { get: (h: string) => h === 'authorization' ? `Bearer ${token}` : null } } as any);
 
  const showNew = await flagService.isEnabled('new_dashboard', {
    userId: user.id,
    plan: user.plan,
  });
 
  return showNew ? <NewDashboard /> : <OldDashboard />;
}

Because the flag check runs on the server, users cannot inspect the response to discover unreleased features. The gated component never reaches the client if the flag is off.

Seeding a flag

Insert a row via a seed script or Drizzle Studio:

// scripts/seed-flags.ts
import { db } from '@/db/drizzle';
import { featureFlagTable } from '@/modules/flag/flag.schema';
 
await db.insert(featureFlagTable).values({
  key: 'new_dashboard',
  enabled: true,
  allowedPlans: ['pro', 'enterprise'],
  allowedUserIds: [],
}).onConflictDoNothing();

Run it with npx tsx scripts/seed-flags.ts. Now only users on the pro or enterprise plan see the new dashboard. Free-tier users get the old experience until you are ready to roll out.

Rolling out to everyone

When the feature is stable and you want everyone to see it, clear the restriction lists:

UPDATE feature_flags
SET allowed_plans = '{}'::text[], allowed_user_ids = '{}'::text[]
WHERE key = 'new_dashboard';

Empty arrays with enabled = true means all users get it. No code change, no redeploy -- the flag evaluates fresh on every request.

Extending the pattern

Once the basics are in place, a few additions make the system production-ready:

  • Admin UI -- a simple table backed by PATCH /api/flags/[key] so non-technical teammates can toggle flags without SQL access
  • Caching -- cache flag lookups in memory with a 60-second TTL to avoid a DB round-trip on every request on high-traffic routes
  • Percentage rollouts -- add a percentage numeric column and check Math.random() < percentage in the service after the plan/user check

What to do next

Feature flags are one of those patterns that feel like overhead until the first time you need to pull back a broken release without a deploy. Add one to your next feature and you will not look back.

The Claude Code boilerplate at boilerplate.iteam-company.com already has Drizzle ORM, Neon DB, and the DDD-lite module structure wired up. Clone it and you can have this feature flag system running in an afternoon -- then use Claude Code to build the admin UI on top of it.