-
Notifications
You must be signed in to change notification settings - Fork 20
Description
Overview
Add a Feature Flags plugin for runtime feature toggling without redeployments. Flags can be simple on/off booleans, percentage rollouts, or user-segment targeting. The admin UI is a clean dashboard for managing flags; the client surface is a lightweight hook and helper that works in Server Components, Client Components, and API routes.
Think a self-hosted LaunchDarkly or Unleash — minimal but genuinely useful for controlled rollouts, kill switches, and A/B experiments.
Core Features
Flag Types
- Boolean flags — simple on/off kill switches
- Percentage rollout — e.g. 20% of requests see the flag as
true(deterministic by session hash) - Segment targeting — enable for specific user IDs, emails, or tag values passed at evaluation time
Admin Dashboard
- Flag list with live status toggle
- Create / edit / archive flags (name, description, type, rollout config)
- Per-flag evaluation log (last N evaluations with context)
- Flag tags for organisation (e.g.
beta,experiment,kill-switch)
Evaluation
- Server-side evaluation (Server Components,
getServerSideProps, API routes) - Client-side evaluation via React hook (
useFlag) - Edge-compatible — no DB call on evaluation if flags are cached
- Flag value cache with configurable TTL (default: 30s) to avoid per-request DB hits
Schema
import { createDbPlugin } from "@btst/stack/plugins/api"
export const featureFlagsSchema = createDbPlugin("feature-flags", {
flag: {
modelName: "flag",
fields: {
key: { type: "string", required: true }, // machine-readable, e.g. "new-checkout-flow"
name: { type: "string", required: true }, // human label
description: { type: "string", required: false },
type: { type: "string", defaultValue: "boolean" }, // "boolean" | "rollout" | "segment"
enabled: { type: "boolean", defaultValue: false },
rolloutPct: { type: "number", required: false }, // 0–100, for type "rollout"
segments: { type: "string", required: false }, // JSON: [{ field, operator, value }]
tags: { type: "string", required: false }, // JSON array
archivedAt: { type: "date", required: false },
createdAt: { type: "date", defaultValue: () => new Date() },
updatedAt: { type: "date", defaultValue: () => new Date() },
},
},
evaluation: {
modelName: "evaluation",
fields: {
flagKey: { type: "string", required: true },
result: { type: "boolean", required: true },
context: { type: "string", required: false }, // JSON: { userId, email, tags }
timestamp: { type: "date", defaultValue: () => new Date() },
},
},
})Plugin Structure
src/plugins/feature-flags/
├── db.ts
├── types.ts
├── schemas.ts
├── evaluate.ts # Core evaluation logic (boolean / rollout / segment) — no DB
├── query-keys.ts
├── client.css
├── style.css
├── api/
│ ├── plugin.ts # defineBackendPlugin — flag CRUD + evaluate endpoint
│ ├── getters.ts # listFlags, getFlagByKey, evaluateFlag
│ ├── mutations.ts # createFlag, updateFlag, archiveFlag, toggleFlag
│ ├── cache.ts # In-memory flag cache with TTL
│ ├── query-key-defs.ts
│ ├── serializers.ts
│ └── index.ts
└── client/
├── plugin.tsx # defineClientPlugin — admin dashboard route
├── overrides.ts # FeatureFlagsPluginOverrides
├── index.ts
├── hooks/
│ ├── use-flag.tsx # useFlag(key, context?) — React Query backed
│ ├── use-flags.tsx # useFlags() — all flags for admin UI
│ └── index.tsx
└── components/
└── pages/
├── flags-page.tsx / .internal.tsx # Flag list + toggle
├── edit-flag-page.tsx / .internal.tsx # Create / edit flag
└── flag-detail-page.tsx / .internal.tsx # Per-flag evaluation log
Routes
| Route | Path | Description |
|---|---|---|
flags |
/feature-flags |
Flag list with live toggle |
newFlag |
/feature-flags/new |
Create flag |
editFlag |
/feature-flags/:key |
Edit flag rollout / segment config |
flagDetail |
/feature-flags/:key/log |
Evaluation log for a single flag |
Evaluation API
Server-side (no HTTP roundtrip — reads from cache then DB):
// In Server Components, generateStaticParams, API routes, etc.
const isEnabled = await myStack.api["feature-flags"].evaluate("new-checkout-flow", {
userId: "user-123",
email: "user@example.com",
tags: ["beta"],
})HTTP endpoint (for client-side and edge evaluation):
GET /api/data/feature-flags/evaluate/:key
GET /api/data/feature-flags/evaluate (bulk — all flags)
Both endpoints accept an optional context query param (base64 JSON) for segment targeting.
React hook:
import { useFlag, useFlags } from "@btst/stack/plugins/feature-flags/client/hooks"
// Single flag
const { data: isEnabled } = useFlag("new-checkout-flow", { userId: session.userId })
// All flags (for feature flag management UI or debugging)
const { data: flags } = useFlags()
// Conditional rendering
if (isEnabled) return <NewCheckout />
return <LegacyCheckout />Evaluation Logic (evaluate.ts)
Pure function, no DB dependency — the cache layer resolves the flag definition first:
export function evaluateFlag(
flag: Flag,
context?: { userId?: string; email?: string; tags?: string[] }
): boolean {
if (!flag.enabled) return false
if (flag.type === "boolean") return true
if (flag.type === "rollout") {
const hash = murmurhash(`${flag.key}:${context?.userId ?? "anonymous"}`) % 100
return hash < (flag.rolloutPct ?? 0)
}
if (flag.type === "segment") {
const segments: SegmentRule[] = JSON.parse(flag.segments ?? "[]")
return segments.every((rule) => matchesRule(rule, context))
}
return false
}Flag Cache
Flags are cached in-process with a configurable TTL to avoid per-request DB hits:
featureFlagsBackendPlugin({
cacheTtlMs: 30_000, // default: 30 seconds
})The cache is invalidated immediately when a flag is toggled or updated via the admin API.
Hooks
featureFlagsBackendPlugin({
cacheTtlMs?: number // default: 30000
onBeforeToggle?: (flag, newValue, ctx) => Promise<void> // throw to prevent toggle
onAfterToggle?: (flag, newValue, ctx) => Promise<void> // audit log, Slack alert, etc.
onEvaluate?: (flag, result, context, ctx) => Promise<void> // analytics / logging
})Consumer Setup
// lib/stack.ts
import { featureFlagsBackendPlugin } from "@btst/stack/plugins/feature-flags/api"
"feature-flags": featureFlagsBackendPlugin({
cacheTtlMs: 10_000,
onAfterToggle: async (flag, enabled) => {
console.log(`Flag "${flag.key}" toggled to ${enabled}`)
},
})// lib/stack-client.tsx
import { featureFlagsClientPlugin } from "@btst/stack/plugins/feature-flags/client"
"feature-flags": featureFlagsClientPlugin({
apiBaseURL: "",
apiBasePath: "/api/data",
siteBasePath: "/pages",
queryClient,
})SSG Support
The admin dashboard is dynamic by nature. For SSG pages that consume a flag, call evaluate at build time and let the result bake into the static HTML — or use ISR for shorter revalidation windows.
// app/pages/checkout/page.tsx
export const revalidate = 30 // ISR — re-evaluate flag every 30s
export default async function CheckoutPage() {
const isEnabled = await myStack.api["feature-flags"].evaluate("new-checkout-flow")
return isEnabled ? <NewCheckout /> : <LegacyCheckout />
}Non-Goals (v1)
- Multi-environment flag configs (prod / staging / dev) — use separate stack instances
- SDKs for non-JS runtimes
- Scheduled flag enables/disables
- Experiment result tracking / statistical significance
- Persistent evaluation log storage (ephemeral in v1)
Plugin Configuration Options
| Option | Type | Description |
|---|---|---|
cacheTtlMs |
number |
Flag cache TTL in milliseconds (default: 30 000) |
hooks |
FeatureFlagsPluginHooks |
onBeforeToggle, onAfterToggle, onEvaluate |
Documentation
Add docs/content/docs/plugins/feature-flags.mdx covering:
- Overview — runtime toggling, kill switches, rollouts, segment targeting
- Flag types — boolean, rollout percentage, segment rules with examples
- Setup —
featureFlagsBackendPlugin+featureFlagsClientPlugin - Server-side evaluation —
myStack.api["feature-flags"].evaluate()in Server Components + API routes useFlaghook — client-side usage + context parameter- SSG with ISR — baking flag values into static pages with
revalidate - Flag cache — TTL configuration and invalidation behaviour
- Schema reference —
AutoTypeTablefor config + hooks - Routes — admin dashboard route table
Related Issues
- Analytics Plugin #74 Analytics Plugin (track experiment exposure events via
onEvaluatehook) - Job Board Plugin #58 Job Board Plugin
- Calendar Booking Plugin #40 Calendar Booking Plugin