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
Admin Dashboard
Evaluation
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
useFlag hook — 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 —
AutoTypeTable for config + hooks
- Routes — admin dashboard route table
Related Issues
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
true(deterministic by session hash)Admin Dashboard
beta,experiment,kill-switch)Evaluation
getServerSideProps, API routes)useFlag)Schema
Plugin Structure
Routes
flags/feature-flagsnewFlag/feature-flags/neweditFlag/feature-flags/:keyflagDetail/feature-flags/:key/logEvaluation API
Server-side (no HTTP roundtrip — reads from cache then DB):
HTTP endpoint (for client-side and edge evaluation):
Both endpoints accept an optional
contextquery param (base64 JSON) for segment targeting.React hook:
Evaluation Logic (
evaluate.ts)Pure function, no DB dependency — the cache layer resolves the flag definition first:
Flag Cache
Flags are cached in-process with a configurable TTL to avoid per-request DB hits:
The cache is invalidated immediately when a flag is toggled or updated via the admin API.
Hooks
Consumer Setup
SSG Support
The admin dashboard is dynamic by nature. For SSG pages that consume a flag, call
evaluateat build time and let the result bake into the static HTML — or use ISR for shorter revalidation windows.Non-Goals (v1)
Plugin Configuration Options
cacheTtlMsnumberhooksFeatureFlagsPluginHooksonBeforeToggle,onAfterToggle,onEvaluateDocumentation
Add
docs/content/docs/plugins/feature-flags.mdxcovering:featureFlagsBackendPlugin+featureFlagsClientPluginmyStack.api["feature-flags"].evaluate()in Server Components + API routesuseFlaghook — client-side usage + context parameterrevalidateAutoTypeTablefor config + hooksRelated Issues
onEvaluatehook)