Skip to content

Feature Flags Plugin #80

@olliethedev

Description

@olliethedev

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
  • SetupfeatureFlagsBackendPlugin + featureFlagsClientPlugin
  • Server-side evaluationmyStack.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 referenceAutoTypeTable for config + hooks
  • Routes — admin dashboard route table

Related Issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions