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'sgetFlag(key, defaultValue)— wrapped in React'scache()(a per-request memo, not a data cache — seenode_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_FLAGSin the same file — every flag actually checked anywhere in the app, with its default. This is the list/admin/flagsrenders toggles for; a flag not in this list isn't discoverable there (same by-hand-sync convention asapp/(admin)/admin/audit/page.tsx'sKNOWN_ACTIONS).lib/actions/flags.ts'ssetFlag(key, enabled)— admin-gated (requireAdmin()), upserts the row, logs an audit entry ("feature_flag.toggled"), and callsrevalidatePath("/admin/flags"). That last call is required, not decorative — see the Next 16 note below.app/(admin)/admin/flags/page.tsx— one row perKNOWN_FLAGSentry, 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) andnotifications(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 callsrevalidatePath/updateTag/refresh, mutates cookies, or redirects — binding an action to a<form>doesn't trigger that by itself. The same bug class shows up inlib/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_FLAGSinlib/flags.ts, then callawait getFlag("your-key", defaultValue)wherever the behavior branches. - Per-user / per-org flags (instead of global) — widen
feature_flagspast its single globalkeyprimary key (e.g. add a nullableorganizationIdcolumn 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).