Feature Flags
A feature flag system with a three-tier transport — Cloudflare Flagship on Workers, an OpenFeature fallback, and local env/JSON flags for dev — plus built-in maintenance mode.
Feature Flags
Valcraven includes a feature flag system in lib/flags.ts. You evaluate flags with
a small typed API (getFlag, getStringFlag, getNumberFlag) and the module
picks the best available backend automatically. In production on Cloudflare it
uses Cloudflare Flagship; in local
dev it reads plain env vars or a flags.json file, so you can use flags
immediately with no Cloudflare account.
Flagship is currently in private beta. The Workers binding is real but the
[[flagship]]block inwrangler.tomlships commented out until you have a Flagshipapp_id. Until then, evaluation falls through to the local tier — which works everywhere — so nothing breaks.
The three transport tiers
lib/flags.ts tries three backends in order and falls to the next on failure
(same pattern as lib/email.ts and lib/db.ts):
| Tier | Environment | How it works |
|---|---|---|
| 1 | Cloudflare Workers | The env.FLAGS Flagship binding — edge evaluation, zero config |
| 2 | Docker / VPS | The OpenFeature SDK over HTTP (@openfeature/server-sdk + @cloudflare/flagship), needs FLAGSHIP_APP_ID + CLOUDFLARE_ACCOUNT_ID + CF_API_TOKEN |
| 3 | Local dev | FLAG_* env vars or a flags.json file — no Cloudflare access needed |
Tier 2's packages are optional. If they aren't installed, or its env vars are missing, evaluation simply falls through to tier 3.
Evaluating a flag
import { getFlag, getStringFlag, getNumberFlag, invalidateFlagCache } from "@/lib/flags";
// Boolean — is this feature on?
const enabled = await getFlag("new-dashboard", false, { userId, plan });
// String — which variant? (A/B testing)
const variant = await getStringFlag("checkout-variant", "control", { userId });
// Number — a tunable parameter
const limit = await getNumberFlag("max-upload-mb", 10, { plan });
// Reset cached local flags (useful between tests)
invalidateFlagCache();The second argument is the default returned when the flag doesn't exist or evaluation fails, so a flag call is always safe.
The optional third argument is a FlagContext — user attributes passed to
Flagship's targeting rules:
interface FlagContext {
userId?: string; // per-user targeting and percentage rollouts
plan?: string; // plan-based gating ("free" vs "pro")
email?: string; // email-based targeting
[key: string]: string | undefined; // custom attributes
}Local development
Option 1 — environment variables
Set FLAG_<UPPERCASE_HYPHENATED_KEY> in .env:
FLAG_MAINTENANCE_MODE=true
FLAG_NEW_DASHBOARD=true
FLAG_MAX_UPLOAD_MB=50
FLAG_CHECKOUT_VARIANT=new-designThe key is lowercased and de-underscored: FLAG_MY_FEATURE → my-feature. Values
are parsed — "true"/"false" become booleans, numeric strings become numbers,
everything else stays a string.
Option 2 — flags.json
Create flags.json in the project root:
{
"maintenance-mode": false,
"new-dashboard": true,
"max-upload-mb": 25,
"checkout-variant": "control"
}FLAG_* env vars take precedence over flags.json.
Reading flags in the browser
For React components, fetch the /api/flags endpoint rather than calling
lib/flags.ts directly (it's server-only):
const res = await fetch("/api/flags");
const { flags } = await res.json();
// e.g. { "maintenance-mode": false, "new-dashboard": true }The endpoint evaluates flags with the signed-in user's context (userId, plan, email); unauthenticated requests get global defaults.
The endpoint has an explicit list of flags in
app/api/flags/route.ts— it does not auto-discover them. When you add a flag you want on the client, add a line there too.
Maintenance mode
A built-in flag demonstrates the system end to end. Toggle it with:
FLAG_MAINTENANCE_MODE=trueWhen it's on, middleware.ts rewrites all public routes to /maintenance. These
paths stay accessible so you can still operate the app:
/api/*— API endpoints/admin/*— the admin panel/_next/*,/favicon*— static assets/maintenance— the maintenance page itself
Middleware runs in the Edge runtime, so it reads
process.env.FLAG_MAINTENANCE_MODEdirectly instead of callinglib/flags.ts(which usesrequire/fsand is Node.js-only). For full Flagship evaluation, usegetFlag()in API routes and server components.
Adding a new flag
- (Production) Create it in Flagship — Cloudflare dashboard → Compute → Flagship → your app → Create flag. Set the key, type, default, and any targeting rules.
- Use it in code — call the matching getter with a sensible default:
const enabled = await getFlag("your-flag-key", false, context); - Expose it to the client (if needed) — add a line to
app/api/flags/route.ts. - Override it locally — add
FLAG_YOUR_FLAG_KEY=...to.envor an entry inflags.json.
Turning on Flagship in production
The binding is declared (commented out) in wrangler.toml:
# [[flagship]]
# binding = "FLAGS"
# app_id = "<YOUR_FLAGSHIP_APP_ID>"Once you have an app_id, uncomment the block, set it, and regenerate types with
npx wrangler types. No code changes are needed — tier 1 activates automatically.
Key files
| File | Purpose |
|---|---|
lib/flags.ts | Core module — three-tier transport and all getters |
app/api/flags/route.ts | Client-facing flag evaluation endpoint |
app/maintenance/page.tsx | Maintenance mode landing page |
middleware.ts | Maintenance mode rewrite |
wrangler.toml | Flagship binding declaration (commented until you have an app_id) |
flags.json | Optional local flag overrides |
Related
- Environment Variables — the
FLAG_*and Flagship env vars - The dev wiki page at /admin/docs/feature-flags covers targeting rules in more depth.
Waitlist & subscribers
Gate signups behind an invite-only waitlist with referrals, and manage newsletter subscribers with one-click unsubscribe.
Search
The three search surfaces in the template — full-text item search (FTS5/LIKE, session-scoped), public docs search, and admin-only dev wiki search — all built on fumadocs and SQLite.