Feature flags built natively for Next.js. Ship safely, roll out gradually, kill instantly.
This document is a snapshot of every feature currently implemented in the Togglebit codebase, organized by surface area (SDK, API/backend, Dashboard/frontend) and complemented by a user-journey view of what a user can actually do today.
Togglebit is a three-tier feature flag platform:
| Tier | Package / Path | Runtime | Purpose |
|---|---|---|---|
| SDK | packages/sdk (togglebit on npm, v0.1.2) |
Browser + Node (Next.js App Router first) | Evaluate flags from customer apps |
| API | apps/api |
FastAPI on Python 3 | Eval hot path + management CRUD + auth |
| Dashboard | apps/dashboard |
Next.js 14 App Router | Self-serve management UI for humans |
Storage is Postgres 16 (source of truth) + Redis 7 (eval cache + API-key cache + usage counters). Auth is Clerk (users) and HMAC-hashed API keys (SDK). Everything ships as a single-port nginx-fronted Docker stack.
A brand-new user can go from zero to a shipped feature flag in ~60 seconds:
- Land on marketing site — animated conversion landing page (hero, social proof, problem, preview, benefits, how-it-works, testimonials, pricing with monthly/annual toggle, final CTA).
- Sign up / sign in — Clerk-hosted flows at
/sign-inand/sign-up. - Get redirected to onboarding — the
OrgGatemiddleware catches users without an org and sends them to/onboarding. - Create an organization — pick a name, auto-suggested slug, become the
owner. Or accept a pending invitation to an existing org (surfaced automatically by email match). - Invite teammates — four-role RBAC (owner / admin / developer / member), copyable invite link, 7-day expiry, revoke, change role, remove member.
- Switch between organizations — top-of-sidebar org switcher, persisted in URL via
?org=slug. - Generate an API key — name + environment (
dev/staging/prod), raw key shown exactly once with copy affordance, HMAC-hashed server-side. - Create a feature flag — four types supported:
boolean— simple on/offpercentage— deterministic hash-bucketed rollout 0–100%segment— targeting rules (AND) on arbitrary context attributescombined— segment gate + percentage rollout stacked
- Tune per environment — each flag has independent
enabled,rollout_pct, andrulesfor dev / staging / prod, edited on the flag-detail page with a live range slider and rule builder. - Quick-toggle from the flags list — one-click switch to enable/disable in the selected environment.
- Follow the Quickstart — copy-paste
npm install togglebit, a Provider snippet, a client-sideuseFlagsnippet, and a server-sidegetFlagsnippet (API key prefix is pre-filled from the user's real keys). - Switch environments — sidebar env switcher, synced to
?env=across routes. - Integrate in their Next.js app — use the SDK (details in §3). The Togglebit dashboard itself dogfoods the SDK via a badge that only renders when the
nav-refreshflag is enabled. - Delete or revoke — revoke API keys, delete flags, delete the org (owner-only danger zone).
- Theme & shell polish — light/dark toggle, collapsible sidebar with
Ctrl/Cmd+Bshortcut, localStorage persistence, mobile drawer with backdrop, responsive layouts throughout.
| Export | Entry | Description |
|---|---|---|
TogglebitProvider |
togglebit |
React context provider that captures config and eagerly constructs the client |
useFlag(key, context) |
togglebit |
Client-side React hook returning boolean |
TogglebitClient |
togglebit |
Imperative, framework-agnostic client class |
getClient(config) |
togglebit |
Singleton-with-signature accessor (avoids reinstantiation per render) |
getFlag(key, context, config) |
togglebit/server |
Zero-dependency server-side helper for RSC/Route Handlers |
| Types | both entries | FlagKey, FlagContext, TogglebitConfig, EvalResponse |
Package is published as dual ESM/CJS with TypeScript declarations, React is an optional peer dependency (the /server entry has zero runtime deps beyond fetch).
- Dual entry points:
togglebitfor client,togglebit/serverfor RSC — no React in the server path. - Hosted-by-default:
baseUrlis optional; defaults to the hosted Togglebit origin. Users only needapiKey+environment. - Base URL normalization: trailing slashes and
/apicollisions (which would double-prefix to/api/api/v1/...) are defensively handled. - Bearer-token auth:
Authorization: Bearer tb_<env>_<token>format. - Context propagation:
userId(required) + arbitrary string attributes (plan,country,email, …) serialized as a JSON query param. - Client-side LRU cache: hand-rolled
LRUCache(no npm dep) with configurablemax(500) andttl(default 30s), keyed onflagKey + JSON(context). - Server-side Next.js fetch-cache integration: on the server,
fetchis called with{ next: { revalidate: cacheTtl } }; on the browser,{ cache: 'no-store' }to prevent false positives. - Failure-safe defaults: any network error, non-2xx response, or missing flag falls back to
config.defaultValue ?? false— the product degrades to "feature off" which is the safe choice. - Singleton with config-signature:
getClienthashes apiKey + environment + baseUrl + cacheTtl + defaultValue; config changes transparently rebuild the client, preserving cache locality otherwise. - React 18/19 compatible: uses
'use client'directives and the modernuseState/useEffectcancellation pattern. - Small surface, tiny footprint:
< 2 kb gzipped(README claim), plainfetch, no axios/zod/etc.
// Client component
import { useFlag } from 'togglebit'
const darkMode = useFlag('dark-mode', { userId: user.id })
// Server component
import { getFlag } from 'togglebit/server'
const enabled = await getFlag('dark-mode', { userId }, {
apiKey: process.env.TOGGLEBIT_API_KEY!,
environment: 'prod',
})Mounted under /api/v1/* behind nginx. Three logical surfaces: eval (hot path), manage (CRUD), orgs (tenancy). Plus health.
GET /api/v1/eval/flags/{key}?userId=...&context={...} — authenticated with an API key.
Pipeline:
- API-key auth (
require_api_key): extract Bearer token, HMAC-SHA256 it withHMAC_SECRET, look up inapi_keystable. Redis cache of the lookup with 5-min TTL (apikey:<hash>→{org_id, environment}).last_used_atis updated on miss. - Config cache:
get_cached_flag(redis, org_id, env, key)— 5-minute Redis TTL; on miss, joinsflags+flag_environmentsin a single query. - Evaluation (
services/eval.py): branches by flagtype:boolean→ returnsenabled.percentage→ deterministic bucketing:md5(flagKey + ":" + userId)[:8]as int mod 100, compared torollout_pct. Same user always gets the same bucket — sticky rollouts.segment→ AND-combined rule evaluation over supplied context.combined→ rule gate first, then percentage bucket.
- Rule operators supported out of the box:
eq,neq,in,not_in,contains,gt,lt. - Reason codes returned to the caller:
flag_disabled,flag_not_found,boolean,percentage_rollout,segment_match,segment_no_match,segment_and_percentage,unknown_type— useful for client-side debugging and audits. - Latency header: every response includes
latency_msmeasured server-side. - Fire-and-forget usage tracking:
asyncio.create_task(track_eval(org_id))increments a Redis keyevals:<org_id>:<YYYY-MM-01>without blocking the response. - Background flush: a lifespan task runs
flush_eval_countsevery 60s, scanning Redis counters withSCAN+GETDELand upserting into theusagetable (monthly rollups withON CONFLICT DO UPDATE).
All routes require a Clerk JWT + X-Org-Id header (slug or UUID), and are wrapped in role-based authorization:
| Route | Method | Roles |
|---|---|---|
/api/v1/manage/flags?env=... |
GET | any member |
/api/v1/manage/flags |
POST | owner / admin / developer |
/api/v1/manage/flags/{key} |
GET | any member |
/api/v1/manage/flags/{key}/environments/{env} |
PATCH | owner / admin / developer |
/api/v1/manage/flags/{key} |
DELETE | owner / admin |
Behaviors:
- Flag creation auto-seeds all three default environments (
dev,staging,prod) withenabled=falseand zero rollout. - Slug validation on key:
^[a-z0-9][a-z0-9\-]{0,62}[a-z0-9]$. - Type whitelist enforced via Pydantic:
boolean | percentage | segment | combined. - Duplicate detection: returns
409with human error. - Per-environment patch is field-by-field — passing only
enabledleavesrollout_pctandrulesintact. - Audit log: every create / update / delete writes to
flag_eventswith old and new JSONB values, action label (created/enabled/disabled/updated/deleted), user id, and environment. - Cache invalidation: on PATCH and DELETE, the Redis entry
flag:<org>:<env>:<key>is dropped so the next eval recomputes.
| Route | Method | Roles |
|---|---|---|
/api/v1/manage/keys |
GET | any member |
/api/v1/manage/keys |
POST | owner / admin |
/api/v1/manage/keys/{id} |
DELETE | owner / admin |
Behaviors:
- Key format:
tb_<environment>_<32-byte urlsafe token>; the raw key is returned exactly once on creation. - Secure at rest: only the HMAC-SHA256 hash and a 12-character prefix are stored. No way to recover a key once dismissed.
- Revocation: deletes the DB row and evicts the Redis auth cache entry immediately — no grace period.
| Route | Method | Purpose |
|---|---|---|
POST /orgs |
Create an org (caller becomes owner) |
|
GET /orgs |
List all orgs the caller is a member of | |
GET /orgs/me |
Current org details (honors X-Org-Id) |
|
GET /orgs/me/members |
List members | |
POST /orgs/me/invitations |
Create invite (owner/admin; admins can't invite owners) | |
GET /orgs/me/invitations |
List invitations | |
POST /orgs/me/invitations/accept |
Accept by token | |
GET /orgs/invitations/pending |
List pending invites for the signed-in email | |
DELETE /orgs/me/invitations/{id} |
Revoke invitation | |
PATCH /orgs/me/members/{user_id}/role |
Change role (guarded against owner-demotion and admin self-promotion) | |
DELETE /orgs/me/members/{user_id} |
Remove member | |
DELETE /orgs/me |
Delete org (owner-only, cascades via FK) |
Invitation flow specifics:
- Token is
secrets.token_urlsafe(24), stored unique. - Unique partial index prevents duplicate pending invites per email per org; resending an invite upserts and resets the 7-day expiry.
- Email is normalized lowercase; basic
@validation. - On accept, membership row is inserted and the invitation marked
accepted_at/accepted_by. - Back-fill migration (
009) copies the invite email ontoorg_members.emailso teammates show real addresses in the UI even if Clerk doesn't surface them.
require_clerk— verifies Clerk JWT via JWKS (cached), extractssub(user id). Resolves the user's primary email from the JWT; falls back to a livehttps://api.clerk.com/v1/users/{id}lookup using the Clerk secret if the JWT doesn't include it. JWKS URL is derived from the Clerk publishable key or overridden viaCLERK_JWKS_URL. Returns clear401/503errors.require_api_key— HMAC compare + Redis cache (described above).require_org_membership— resolves the org fromX-Org-Idheader (accepts UUID or slug) and joins toorg_members. Without the header, picks the user's oldest org. Returns403on non-membership.require_org_roles(*roles)— composes on top of membership; returns403withInsufficient organization role.
Nine SQL migrations in apps/api/migrations:
orgs— tenant root,slugunique,plan(defaultfree), optionalstripe_customer_idalready hooked for future billing.org_members— user_id + role, unique per org.flags— per-orgkeyuniqueness,typedefault boolean,created_byaudit field.flag_environments— (flag_id, environment) uniqueness,rollout_pct CHECK 0–100,rules JSONB.flag_events— append-only audit log with old/new JSONB.api_keys— hashed + prefix +last_used_at.usage— monthly eval counts per org.- Role CHECK constraint (
owner / admin / developer / member) +org_invitationstable with partial unique index on pending invites. org_members.emailcolumn + backfill from accepted invitations.
Indexes are added where they matter: flags(org_id, key), flag_environments(flag_id, environment), flag_environments(org_id, environment), flag_events(flag_id), flag_events(org_id), api_keys(key_hash), org_invitations(org_id).
- Async Postgres via
asyncpgconnection pool created on FastAPI lifespan startup, closed on shutdown. - Async Redis via
redis.asynciowith matching lifespan hooks. - Background task: usage flusher started inside lifespan, cancelled cleanly on shutdown.
- CORS locked to
DASHBOARD_URL, methodsGET/POST/PATCH/DELETE/OPTIONS, headersAuthorization / Content-Type / X-Org-Id. - Health:
GET /healthreturns{status, environment}. - Containerized: own Dockerfile, hot-reload in dev via bind-mount +
uvicorn --reload.
Stack: Next.js 14 · React 19 · Tailwind CSS with custom tokens · shadcn/ui primitives · Radix-backed components · Clerk · Sonner for toasts · Lucide icons · Geist Sans font · Framer Motion on the landing page.
- Public:
/(landing page) - Auth (Clerk):
/sign-in/[[...sign-in]],/sign-up/[[...sign-up]] - App group
(app)— gated by Clerk middleware and byOrgGate(auto-redirect to onboarding if the user has no org):/dashboard— flags list for the active env + create dialog/flags/[key]— per-flag detail with three env cards/flags/new— reserved route stub/keys— API keys list + generation dialog/quickstart— personalized copy-paste integration guide/onboarding— org creation, invitation acceptance, member/role management, org deletion
Middleware (middleware.ts) protects /dashboard, /flags, /keys, /quickstart, /onboarding with Clerk.
- Collapsible sidebar with icon-only collapsed mode on desktop; drawer with dark backdrop on mobile.
Ctrl/Cmd+Btoggle,Escapecloses mobile drawer; body scroll locked when drawer is open.- Collapsed state persisted in
localStorageundertogglebit.sidebar.collapsed. OrgSwitcherfetches orgs via Clerk JWT, reflects current org in?org=slug, listens for a customorgs-changedevent so newly created orgs appear immediately.EnvSwitchermirrors env selection into?env=on the current pathname — server components re-render off the URL.- Header hosts: sidebar toggle, Togglebit dogfood badge, theme toggle (light/dark via
next-themesstyle provider), Clerk<UserButton />.
- Flag list (
FlagTable): per-row name + key + type badge + "Enabled/Disabled in {env}" label + rollout progress bar (for percentage/combined flags) + segment rule count + inline enable/disable switch. Empty state with custom illustration and copy ("Your first flag is one click away"). - Create flag dialog: name → auto-slug → type selector (boolean / percentage / segment / combined) → conditional Initial rollout % input → conditional rule builder for segment flags. All three environments pre-seeded with the chosen config.
- Flag detail page: shows name, key, type badge, description, and three side-by-side
EnvCards fordev,staging,prod— each with its own enable switch, rollout range slider (with custom gradient track), rule builder, and independent Save button. - Rule builder: add/remove condition rows; each row is
attribute + operator + value. Seven operators (equals,not equals,in,not in,contains,greater than,less than). Responsive: stacked layout on mobile, three-column grid on desktop. Fully disabled when the user lacks manage permission. - Flag toggle from the list performs an optimistic
PATCH /environments/{env}with toast feedback on success/failure. - Delete flag: destructive action on the detail page with a confirm dialog; returns to dashboard on success.
- Keys list with name, masked prefix, env badge, created-at, and a hover-revealed revoke button with confirm.
- Generate key dialog: name + environment → shows the raw key exactly once in a copy-able input, with an amber warning that it won't be shown again. Calls
router.refresh()after generation to repopulate the list.
Server-renders the user's highest-privilege API key prefix (never the raw key) and injects it into the code snippets so copy-paste is maximally accurate. Three numbered steps, each with a syntax-tagged copy block: npm install togglebit → TogglebitProvider wrapper → useFlag (client) and getFlag (server) examples. If no keys exist, shows an amber banner with a CTA to generate one.
- First-time flow: if the signed-in email has pending invites, they appear as "Accept" cards at the top. If the user also lands with
?invite=<token>, a fallback card offers to try the literal token. Otherwise an org-creation form (name → auto-slug) lets them become an owner. - Existing-org flow: shows org meta (name, slug, role badge), invite form (email + role selector constrained by the caller's role — admins cannot invite owners), copyable invite URLs, member list with role select / remove / "you" marker / avatar seeded by email hash, danger-zone delete-org (owner-only).
- Multi-org: "Create another organization" collapsible card; firing an
orgs-changedevent refreshes the switcher.
A full conversion-optimized landing page (components/landing/landing-page.tsx, 666 lines) with motion-animated sections:
- Sticky navbar that compacts on scroll, CTA switches to "Dashboard" for signed-in users.
- Hero, social proof, problem, preview, benefits, how-it-works, testimonials.
- Pricing section with monthly/annual toggle and computed annual-savings label.
- Final CTA.
lib/api.tscentralizes every fetch (flags, keys, orgs, members, invitations) as a typedapi.*object. AddsAuthorization: Bearer <clerk jwt>and optionalX-Org-Id.lib/constants.tspicksAPI_URLdifferently per environment: SSR-side it prefersINTERNAL_API_URL(Docker network), browser-side it usesNEXT_PUBLIC_TOGGLEBIT_API_URL/NEXT_PUBLIC_API_URL. Guards against the/apidouble-prefix trap.- Server components call the API directly via
await auth()+getToken()(RSC data fetching); client components calluseAuth().
The dashboard itself uses the Togglebit SDK. TogglebitAppProvider wraps the app when NEXT_PUBLIC_TOGGLEBIT_API_KEY is configured, and TogglebitDogfoodBadge uses useFlag to conditionally render a small UI badge in the header — a built-in demo of the product controlling itself.
- Dark / light themes with CSS custom properties (
--nav-active-bg,--progress-track,--progress-fill,--code-bg,--row-separator, etc.). - Subtle radial-gradient background washes on hero cards, flag detail header, sidebar, and empty states.
- Hover lift, focus rings, keyboard shortcuts hint ("Ctrl/Cmd + B") pinned in the sidebar footer.
- Toast feedback for every mutation via
sonner.
| Capability | Where it lives |
|---|---|
| Multi-tenancy | orgs, org_members, X-Org-Id header, slug routing |
| RBAC (4 roles) | authorization.py dependencies + conditional UI renders |
| Environment separation | flag_environments + scoped API keys + env switcher |
| Audit trail | flag_events table, write on every create/update/delete |
| Usage metering | Redis counter + 60s flush job → usage table |
| Caching | Redis: API-key (5 min), flag config (5 min); SDK: LRU 30s; Next fetch cache (server) |
| Security | HMAC-hashed API keys, Clerk JWKS JWT verification, CORS lockdown, role checks on every mutation, confirm-on-delete everywhere |
| DX | Hosted-default SDK, dual ESM/CJS, React-optional, Next.js App Router aware, sub-2kb gzipped |
- Single entry port — everything is fronted by an nginx container:
/→ dashboard,/api/*→ API. One open port (configurableAPP_PORT) is enough to host on any VPS. - Dev stack (
docker-compose.yml): API with--reload, dashboard dev server with bind-mount + volume-cachednode_modules, Postgres 16, Redis 7 with password + AOF. - Prod stack (
docker-compose-prod.yml) mirrors the same layout with production-tuned settings. - Healthchecks on Postgres (
pg_isready) and Redis (redis-cli ping) gate startup. - One-command deploy:
pnpm docker:devorpnpm docker:prod. Dashboard can also be hosted on Vercel with the API talking via theINTERNAL_API_URL/ publicNEXT_PUBLIC_API_URLsplit.
What it is. A cloud-native feature flag service purpose-built for Next.js: a tiny React-optional SDK, a Postgres-backed management API with a Redis hot-path cache, and a production-quality multi-tenant dashboard.
Why it's defensible.
- Next.js-first ergonomics: hosted default,
useFlagfor client components,getFlagfor RSC — zero boilerplate, nobaseUrlrequired. - Correct by default: fail-closed to
defaultValue ?? false, HMAC-hashed keys, one-shot key display, audit log on every mutation, cache invalidated on every write. - Operationally honest:
latency_msin every response, deterministic sticky percentage rollouts, per-env isolation, full RBAC out of the box. - Portable: one nginx port, Docker Compose, Postgres + Redis — runs on a $5 VPS just as well as Fly/Railway/Vercel.
What a user gets in their first hour. Sign up → create org → invite team → generate keys → create a boolean, percentage, segment, or combined flag → target users via 7 rule operators across arbitrary context attributes → toggle per environment → ship with the SDK → every change is audited, cached, and observable.
What's on the rails but not yet exposed. Billing (stripe_customer_id column + monthly usage rollups already writing), analytics dashboards (events + usage data are captured), and a public /flags/new route stub.