Live demo — shared accounts, sample data, reset regularly. Don't enter anything real.Get the source

Feature Flags

Feature Flags

What it does

DB-backed boolean flags — no third-party flag service. An admin toggles a flag at /admin/flags; the app checks it with getFlag(key, default) and behavior changes on the very next request, no deploy.

How to configure it

No env vars. A flag with no row in the feature_flags table yet reads as its call site's defaultValue — /admin/flags only writes a row once an admin actually flips something away from default.

How it works

  • lib/flags.ts's getFlag(key, defaultValue) — wrapped in React's cache() (a per-request memo, not a data cache — see node_modules/next/dist/docs), so multiple call sites checking the same key in one render (e.g. the layout and the page it wraps) issue one query, not one per call site. A fresh request always re-reads the DB.
  • KNOWN_FLAGS in the same file — every flag actually checked anywhere in the app, with its default. This is the list /admin/flags renders toggles for; a flag not in this list isn't discoverable there (same by-hand-sync convention as app/(admin)/admin/audit/page.tsx's KNOWN_ACTIONS).
  • lib/actions/flags.ts's setFlag(key, enabled) — admin-gated (requireAdmin()), upserts the row, logs an audit entry ("feature_flag.toggled"), and calls revalidatePath("/admin/flags"). That last call is required, not decorative — see the Next 16 note below.
  • app/(admin)/admin/flags/page.tsx — one row per KNOWN_FLAGS entry, each a <form action={setFlag.bind(null, key, !enabled)}> toggle button.
  • The two flags that ship wired up, useful as worked examples: uploads (app/(app)/settings/page.tsx + app/(app)/settings/organization/page.tsx — storageConfigured = isStorageConfigured() && (await getFlag("uploads", true)), so the upload UI can be turned off even when S3 is configured) and notifications (app/(app)/layout.tsx — gates whether the bell renders; notify() keeps writing rows regardless, so nothing is lost while it's off and flipping it back on reveals the backlog).
  • Next 16 gotcha, verified against this app, not assumed from older training data (see AGENTS.md): a Server Action's response only re-renders the invoking route if the action calls revalidatePath/updateTag/refresh, mutates cookies, or redirects — binding an action to a <form> doesn't trigger that by itself. The same bug class shows up in lib/actions/admin-users.ts — without it the toggle button here would keep showing the old On/Off state after a click until a manual reload.

How to extend it

  • A new flag — add an entry to KNOWN_FLAGS in lib/flags.ts, then call await getFlag("your-key", defaultValue) wherever the behavior branches.
  • Per-user / per-org flags (instead of global) — widen feature_flags past its single global key primary key (e.g. add a nullable organizationId column and check the more specific row first, falling back to the global one) — getFlag() is the one function that would need to change shape.

How to remove it

Delete lib/flags.ts, lib/actions/flags.ts, app/(admin)/admin/flags/, the feature_flags table (regenerate a migration), the "Feature flags" admin sidebar link, and the two getFlag() call sites in app/(app)/settings/page.tsx + app/(app)/settings/organization/page.tsx (revert storageConfigured to just isStorageConfigured()) and app/(app)/layout.tsx (revert to always rendering the bell).