Feature Flags Architecture
This document explains how Feature Flags work internally — tracing the path from flag declaration to UI consumption.
Core Principle
Code defines what features can exist.
Remote tools only suggest values.
The FeatureRegistry in core/registry.ts is the single source of truth. GrowthBook (or any provider) cannot introduce new features, change defaults, or affect behavior unless the code explicitly allows it.
Architectural Layers

Each layer has a single responsibility and cannot bypass the layer below it.
Layer 1: Feature Registry (Allow-list)
File: core/registry.ts
The registry is a TypeScript object declaring every valid feature flag:
export const FeatureRegistry = {
'enable-google-signin': {
key: 'enable-google-signin',
default: true,
owner: 'identity',
lifecycle: 'capability',
description: 'Enable Google Sign-In option on the login page',
exposure: 'server',
},
// ...
} satisfies FeatureFlagRegistry;
Each flag definition includes:
| Field | Type | Purpose |
|---|---|---|
key |
string |
Globally unique identifier (must match object key) |
default |
boolean | string | number |
Safe fallback when provider is unavailable |
owner |
string |
Team or domain that owns the flag |
lifecycle |
'release' | 'capability' | 'experiment' | 'kill' |
Governance category |
exposure |
'server' | 'client' |
Where the flag may be consumed |
description |
string |
Human-readable intent |
Helper exports:
FeatureFlagKeys— array of all registered flag keysgetFeatureDefinition(key)— lookup with explicit error on unknown keys
Invariant: if a flag is not in this object, it does not exist for the platform.
Layer 2: Feature Provider (Adapter)
Interface: providers/provider.interface.ts
interface FeatureProvider {
evaluate(key: string, context: FeatureContext, fallback: FeatureFlagValue): Promise<FeatureFlagValue>;
}
Providers are replaceable adapters. The contract requires:
- Deterministic results for a given
(key, context)pair - Must never throw on missing flags
- Must always fall back to the provided default on failure
GrowthBook Provider
Files: providers/growthbook/provider.ts, providers/growthbook/client.ts
The production provider. createGrowthBookClient() initializes the SDK:
- Reads
GROWTHBOOK_API_HOSTandGROWTHBOOK_CLIENT_KEYfrom config - Calls
client.init({ streaming: true, skipCache: true }) - Returns
nullif config is missing (triggers Noop fallback) - Marked
server-only— cannot be imported on the client
GrowthBookFeatureProvider.evaluate() calls growthbook.setAttributes(context) then growthbook.getFeatureValue(key, fallback). Errors are caught and return the fallback.
Noop Provider
File: providers/noop/provider.ts
Used when no external system is configured. Always returns the fallback value:
async evaluate(_key, _context, fallback) {
return fallback;
}
This ensures the app works with zero external dependencies.
Layer 3: Feature Resolver (Enforcement)
File: runtime/resolver.ts
The resolver is the enforcement point. resolveFeatures() takes the registry, a provider, and a context:
async function resolveFeatures(
registry: FeatureFlagRegistry,
provider: FeatureProvider,
context: FeatureContext
): Promise<ResolvedFeatures>;
For each registry-declared flag, it:
- Queries the provider — wrapped in try/catch, falls back to
definition.defaulton error - Enforces kill-switch dominance — if
lifecycle === 'kill', forcesvalue = (value === true) - Type-guards the result — if
typeof value !== typeof definition.default, falls back to default - Freezes the output —
Object.freeze(resolved)makes the snapshot immutable

Invariants:
- Only registry-declared flags are evaluated — remote-only flags are ignored
- Provider failures always fall back to defaults
- Kill switches always win
- Returned object is immutable
Layer 4: UI Mapper (Intent Projection)
File: runtime/uiMapper.ts
The mapper converts infrastructure flag keys into UI-intent booleans:
interface UIFeatureFlags {
showGoogleSignin: boolean;
showMicrosoftSignin: boolean;
enableParallelChunkProcessing: boolean;
}
function mapToUIFeatures(flags: ResolvedFeatures): UIFeatureFlags {
return {
showGoogleSignin: flags['enable-google-signin'] === true,
showMicrosoftSignin: flags['enable-microsoft-signin'] === true,
enableParallelChunkProcessing: flags['enable-parallel-chunk-processing'] === true,
};
}
This layer:
- Hides raw flag keys from the UI
- Renames flags to UX-focused concepts (
enable-google-signin→showGoogleSignin) - Strict-equals
=== true— non-boolean values orundefinedresolve tofalse - Security-sensitive flags never leak to client
Delivery Pipeline
API Route
File: src/app/api/platform/features/route.ts
GET /api/platform/features
The route handler:
- Reads
siteId,site, andidcookies from the request - Builds a
FeatureContext— anonymous{ env }if cookies are missing, or{ userId, siteId, site, env }if present - Creates a GrowthBook client (or falls back to Noop)
- Calls
resolveFeatures(FeatureRegistry, provider, context) - Maps through
mapToUIFeatures() - Returns
UIFeatureFlagsas JSON
Service Layer
File: src/services/FeatureFlagService.ts
class FeatureFlagService {
static async list(): Promise<UIFeatureFlags> {
// Fetches from /api/platform/features via AsyncHandler
// Falls back to {} on error
}
}
React Integration
File: src/context/FeatureFlagContext.tsx
// Root layout (src/app/layout.tsx)
const featureFlags = await FeatureFlagService.list();
<FeatureFlagContext features={featureFlags}>
{children}
</FeatureFlagContext>
// Any client component
const { showGoogleSignin } = useFeatureFlagContext();
The context provider is a memoized component. useFeatureFlagContext() throws an AppError if called outside the provider.
Context-Based Resolution
Feature flags are resolved based on available context, not application pages.
Anonymous (Pre-auth)
When cookies are missing:
{
env: 'production';
}
Authenticated (Post-auth)
When cookies are present:
{ userId: 'u_123', siteId: 's_456', site: 'example', env: 'production' }
Missing context automatically results in fallback behavior — the resolver does not distinguish between these phases.
Failure Model

At every failure point, the system degrades gracefully to registry defaults. The application never crashes due to feature flag infrastructure.
Type System
All types are defined in core/types.ts:
type FeatureFlagValue = boolean | string | number;
type FeatureFlagLifecycle = 'release' | 'capability' | 'experiment' | 'kill';
type FeatureFlagExposure = 'server' | 'client';
type FeatureContext = Record<string, string | number | boolean>;
type ResolvedFeatures = Readonly<Record<string, FeatureFlagValue>>;
interface FeatureFlagDefinition<T extends FeatureFlagValue> {
key: string;
default: T;
owner: string;
description: string;
lifecycle: FeatureFlagLifecycle;
exposure: FeatureFlagExposure;
}
Current Registry
| Flag Key | Default | Owner | Lifecycle | Exposure |
|---|---|---|---|---|
enable-google-signin |
true |
identity | capability | server |
enable-microsoft-signin |
false |
identity | capability | server |
enable-parallel-chunk-processing |
true |
platform | capability | server |
Design Decisions
- Code-first declaration — remote tools cannot introduce flags. This prevents dangling flags and makes audit trivial.
- Kill-switch dominance — flags with
lifecycle: 'kill'are forced to booleantrue/false. They cannot be overridden to arbitrary values. - Type safety at runtime — if a provider returns a type that doesn't match
definition.default, the resolver silently falls back. This prevents a string value from appearing where a boolean is expected. - Frozen output —
Object.freeze()on resolved features prevents mutation after resolution. Consumers get a read-only snapshot. - server-only imports —
createGrowthBookClient()uses theserver-onlypackage to enforce that GrowthBook SDK code never leaks into client bundles.