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.
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.
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 userallowedPlans -- 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 testersRun the migration after adding the schema:
npm run db:generate
npm run db:migrate
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:
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.
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.
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.
Once the basics are in place, a few additions make the system production-ready:
PATCH /api/flags/[key] so non-technical teammates can toggle flags without SQL accesspercentage numeric column and check Math.random() < percentage in the service after the plan/user checkFeature 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.