@mergedapp/feature-flags
v0.1.2
Published
Type-safe client SDK for Merged feature flags with ES256 JWT verification
Readme
@mergedapp/feature-flags
Warning This SDK is not ready for public use yet. The package is public, but you will not be able to finish setting up feature flags until the main app is available. We will do a proper launch soon.
Type-safe, generic feature flag client SDK with ES256 JWT verification, code generation, and React hooks.
Additional integrations now ship on dedicated subpaths:
@mergedapp/feature-flags/react@mergedapp/feature-flags/openfeature/web@mergedapp/feature-flags/openfeature/server@mergedapp/feature-flags/nestjs
The SDK reads already-evaluated flag values for a specific organization and environment. Authoring concepts such as stable codeKeys, environment overrides, fallback values, targeting groups, percentage rollouts, and IP, country, region, or attribute targeting are configured in the dashboard and resolved by the API before values reach the client.
Installation
npm install @mergedapp/feature-flagsIf you want OpenFeature interoperability or NestJS helpers, install the matching peer packages too:
npm install @mergedapp/feature-flags @openfeature/core @openfeature/web-sdk @openfeature/server-sdknpm install @mergedapp/feature-flags @nestjs/common @nestjs/core rxjsQuick Start
1. Generate Typed Flags
Run the CLI to fetch your flag definitions and generate a type-safe TypeScript file:
npx merged-ff generate --api-url=<coming_soon> --client-key=lk_pub_your_key_here --organization-id=org_123This creates ./src/generated/feature-flags.ts containing a createClient factory, typed hooks, and type definitions.
2. Create the Client
import { createClient } from "./generated/feature-flags"
const client = createClient({
apiUrl: "<coming_soon>",
clientKey: "lk_pub_your_key_here",
organizationId: "org_123",
environmentId: "env_prod",
evaluationContext: {
attributes: {
user: {
plan: { code: "pro" },
role: "admin",
},
cart: {
items: [{ sku: "sku_123" }],
},
},
},
})
await client.initialize()
// Fully typed -- flag code keys are autocompleted, return types are inferred
if (client.isEnabled("enableDarkMode")) {
enableDarkMode()
}
const retries = client.getValue("maxRetries") // number | undefined
// Compile error -- "typo" is not a valid flag code key
client.isEnabled("typo") // TS Error: Argument of type '"typo"' is not assignableThe generated file uses stable codeKey values as the SDK-facing identifiers. You can rename a flag's display name in the dashboard without breaking typed client lookups, but changing the codeKey is a breaking change for generated code.
Using a Config File
Create a featureflags.config.json in your project root. featureflags.config.js, .mjs, and .cjs are also supported:
{
"apiUrl": "<coming_soon>",
"clientKey": "lk_pub_your_key_here",
"organizationId": "org_123",
"outputPath": "./src/generated/feature-flags.ts"
}Then run without arguments:
npx merged-ff generateEnvironment variables FEATURE_FLAG_API_URL, FEATURE_FLAG_CLIENT_KEY, and FEATURE_FLAG_ORGANIZATION_ID are also supported.
Code Generation
The merged-ff generate CLI produces a TypeScript file with these exports:
FLAGS
A constant object mapping stable code keys to stable UUIDs:
export const FLAGS = {
enableDarkMode: "550e8400-e29b-41d4-a716-446655440000",
maxUploadSize: "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
} as constFlagValues
An interface mapping each flag code key to its TypeScript type:
export interface FlagValues {
enableDarkMode: boolean
maxUploadSize: number
}createClient(config)
Creates a MergedFeatureFlags<FlagValues> instance with flagIds pre-configured from the generated FLAGS mapping. Provide the API origin plus the organization and environment scope used for evaluation:
import { createClient } from "./generated/feature-flags"
const client = createClient({
apiUrl: "<coming_soon>",
clientKey: "lk_pub_your_key_here",
organizationId: "org_123",
environmentId: "env_prod",
})
await client.initialize()
client.isEnabled("enableDarkMode") // boolean -- autocompleted
client.getValue("maxUploadSize") // number | undefined -- inferred
client.isEnabled("nonExistent") // Compile errorThe client receives the final evaluated value for the configured organization and environment. If your dashboard configuration uses environment overrides, targeting groups, and percentage rollouts, the SDK sees the resolved result rather than the raw authoring definition.
If the active targeting rules or rollout bucketing depend on application attributes, pass them through evaluationContext.attributes. The server evaluates nested object paths like user.plan.code and indexed array paths like cart.items[0].sku.
Evaluation Model
Feature flag authoring happens in layers:
- Flag default value -- The base value on the flag definition.
- Environment override -- An environment-specific configuration with its own enabled state, fallback value, and optional rollout.
- Targeting groups -- Ordered groups inside an environment override. Each group can return a different value when its matchers apply and can also have its own optional rollout.
Targeting groups can currently use four matcher types:
- IP range targeting -- IPv4 or IPv6 CIDR ranges.
- Country targeting -- ISO 3166-1 alpha-2 country codes.
- Region targeting -- ISO 3166-2 subdivision codes.
- Attribute targeting -- Caller-provided paths resolved from
evaluationContext.attributes.
When the server evaluates a request:
- It picks the current organization and environment scope.
- It applies the enabled environment override, if one exists.
- It evaluates targeting groups for that environment.
- Matchers can combine request-derived data such as IP, country, and region with caller-provided attributes from
evaluationContext. - If a matching targeting group has a percentage rollout, the server performs deterministic bucketing before returning that group's value.
- If multiple IP groups match, the most specific CIDR wins.
- If specificity ties, the earlier group wins.
- If no targeting group resolves a value, the environment fallback value is considered.
- If the environment fallback has a percentage rollout, the server performs deterministic bucketing before returning that value.
- If no environment override applies, the base flag default value is returned.
For attribute targeting, the SDK does not infer values automatically. Your application is responsible for building the context object and updating it when the request scope changes.
The SDK never performs client-side rollout bucketing. Percentage rollout is evaluated server-side so the same logic applies across browser, server, and internal API callers.
client.setEvaluationContext({
attributes: {
user: {
plan: { code: "enterprise" },
locale: "en-US",
},
},
})
await client.refresh()Durable snapshots
The SDK now persists the last successful signed evaluation automatically:
- browsers use
localStorage - non-browser runtimes use a local file store under the OS temp directory
- persistence is isolated by a scope key derived from
apiUrl, org, environment, team, client key fingerprint, and a canonicalizedevaluationContexthash
If the flag service is unavailable later, the SDK restores the last successful snapshot for the exact same scope and keeps serving it until a newer one replaces it.
You can disable or override persistence:
import { createClient, createFileFeatureFlagSnapshotStore } from "@mergedapp/feature-flags"
const client = createClient({
apiUrl: "<coming_soon>",
clientKey: "lk_pub_your_key_here",
organizationId: "org_123",
environmentId: "env_prod",
snapshotPersistence: {
store: createFileFeatureFlagSnapshotStore(),
keyPrefix: "my-app-flags",
},
})Set snapshotPersistence: false to disable durable snapshots entirely.
OpenFeature Integration
The native lube SDK remains the primary API. OpenFeature support is additive for teams that already standardize on OpenFeature.
Use separate runtime-specific subpaths because OpenFeature itself publishes different provider contracts for browser and server runtimes:
@mergedapp/feature-flags/openfeature/web@mergedapp/feature-flags/openfeature/server
Web provider
import { OpenFeature } from "@openfeature/web-sdk"
import { createOpenFeatureWebProvider } from "@mergedapp/feature-flags/openfeature/web"
await OpenFeature.setProviderAndWait(
createOpenFeatureWebProvider({
apiUrl: "<coming_soon>",
clientKey: "lk_pub_your_key_here",
organizationId: "org_123",
environmentId: "env_prod",
}),
)Server provider
import { OpenFeature } from "@openfeature/server-sdk"
import { createOpenFeatureServerProvider } from "@mergedapp/feature-flags/openfeature/server"
await OpenFeature.setProviderAndWait(
createOpenFeatureServerProvider({
apiUrl: "<coming_soon>",
clientKey: "lk_sec_your_key_here",
organizationId: "org_123",
environmentId: "env_prod",
}),
)The default context mapper:
- maps OpenFeature
targetingKeytoevaluationContext.attributes.subject.key - copies other OpenFeature fields into
evaluationContext.attributes - converts
Datevalues to ISO strings - merges request context over any provider-level base
evaluationContext
The package also exports helper hooks:
createStaticContextHook()createLoggingHook()
Tracking is intentionally not supported yet.
NestJS Integration
Use the @mergedapp/feature-flags/nestjs subpath when you want Nest-native decorators, guards, interceptors, or an
injectable feature-flag service.
import { Module } from "@nestjs/common"
import { FeatureFlagsModule } from "@mergedapp/feature-flags/nestjs"
@Module({
imports: [
FeatureFlagsModule.forRoot({
apiUrl: "<coming_soon>",
clientKey: "lk_sec_your_key_here",
organizationId: "org_123",
environmentId: "env_prod",
}),
],
})
export class AppModule {}The Nest layer is native-only and evaluates directly through the Merged SDK. If you want OpenFeature inside NestJS, use the OpenFeature Nest integration separately and point it at @mergedapp/feature-flags/openfeature/server.
Exports include:
FeatureFlagsModuleFeatureFlagsServiceRequireFeatureFlag()for controllers/routesFeatureFlagGate()for service methodsFeatureFlagContextInterceptorFeatureFlagContext
Example controller gate:
import { Controller, Get } from "@nestjs/common"
import { RequireFeatureFlag } from "@mergedapp/feature-flags/nestjs"
@Controller("beta")
export class BetaController {
@Get()
@RequireFeatureFlag({ flagKey: "enableBetaDashboard" })
list() {
return { ok: true }
}
}Example service-method gate:
import { Injectable } from "@nestjs/common"
import { FeatureFlagGate, FeatureFlagsService } from "@mergedapp/feature-flags/nestjs"
@Injectable()
export class BillingService {
constructor(public readonly featureFlags: FeatureFlagsService) {}
@FeatureFlagGate({ flagKey: "enableNewBillingFlow" })
async charge() {
return { ok: true }
}
}createTypedHooks<FlagValues>()
Generates fully typed React hooks. The generated file exports pre-built hooks:
export const { FeatureFlagProvider, useFeatureFlag, useFeatureFlags, useFeatureFlagClient, useFeatureFlagStatus } =
createTypedHooks<FlagValues>()Turbo Pipeline
Add the generate command to your turbo.json so flags are regenerated before builds:
{
"tasks": {
"generate:flags": {
"inputs": ["featureflags.config.json"],
"outputs": ["src/generated/feature-flags.ts"],
"cache": false
},
"build": {
"dependsOn": ["generate:flags"]
}
}
}React Integration
Use the generated typed hooks for full type safety:
import { createClient } from "./generated/feature-flags"
import { FeatureFlagProvider, useFeatureFlag, useFeatureFlags } from "./generated/feature-flags"
const client = createClient({
apiUrl: "<coming_soon>",
clientKey: "lk_pub_your_key_here",
organizationId: "org_123",
environmentId: "env_prod",
evaluationContext: {
attributes: {
user: {
plan: { code: "pro" },
},
},
},
})
function App() {
return (
<FeatureFlagProvider blockUntilReady={false} client={client}>
<Dashboard />
</FeatureFlagProvider>
)
}
function Dashboard() {
// Fully typed: "enableDarkMode" is autocompleted, value is boolean | undefined
const { enabled, value } = useFeatureFlag("enableDarkMode")
const allFlags = useFeatureFlags()
if (enabled) {
return <DarkDashboard />
}
return <LightDashboard />
}The provider calls client.initialize() on mount and client.destroy() on unmount automatically. Flags update reactively via useSyncExternalStore. blockUntilReady is required so each integration chooses whether the app should render immediately or wait for the first flag payload.
React Subpath
The @mergedapp/feature-flags/react subpath is intentionally low-level. It exports FeatureFlagProvider and createTypedHooks() so generated bindings can be created, but applications should import useFeatureFlag, useFeatureFlags, useFeatureFlagClient, and useFeatureFlagStatus from their generated ./generated/feature-flags file.
SSR Hydration
Pass pre-evaluated flags to avoid a fetch on the server:
function App({ serverFlags }: { serverFlags: EvaluatedFlag[] }) {
return (
<FeatureFlagProvider blockUntilReady={false} client={client} initialFlags={serverFlags}>
<Dashboard />
</FeatureFlagProvider>
)
}When initialFlags is provided, the provider renders with those values immediately. Once the client initializes on the client side, live flags take over seamlessly.
Configuration Options
| Option | Type | Default | Description |
| --------------------- | ------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
| apiUrl | string | (required) | Base URL for the feature flag API |
| clientKey | string | (required) | API key with lk_pub_* or lk_sec_* prefix |
| organizationId | string | (required) | Organization scope sent to the signed evaluation endpoint |
| environmentId | string | (required) | Environment scope sent to the signed evaluation endpoint |
| teamId | string | - | Optional team scope sent to the signed evaluation endpoint |
| publicKey | string | auto-fetch | PEM-format ES256 public key for JWT verification |
| refreshInterval | number | 60000 | Polling interval in ms. Set to 0 to disable polling. |
| snapshotPersistence | false \| { store?: FeatureFlagSnapshotStore; keyPrefix?: string } | auto | Durable last-known-good snapshot persistence. Browser defaults to localStorage; non-browser defaults to a file store in the OS temp dir. |
| evaluationContext | FeatureFlagEvaluationContext | - | Optional caller-provided context used for attribute targeting |
| flagIds | Record<string, string> | - | Mapping of stable code keys to flag IDs. Auto-configured when using generated createClient. |
| onError | (error: Error) => void | - | Called when a refresh or verification error occurs |
| onFlagsChanged | (flags: EvaluatedFlag[]) => void | - | Called when flag values change after a refresh |
evaluationContext uses this shape:
type FeatureFlagEvaluationContext = {
attributes?: Record<string, unknown>
}Recommended usage:
- Put application-owned targeting inputs in
attributes. - Use nested objects for stable domain structure, for example
user.plan.code. - Use arrays only when position matters or when you plan to target with list membership.
- Treat client-provided values as targeting inputs, not as authorization guarantees.
Error Handling
The SDK exports three error classes:
FeatureFlagError-- Base class for all SDK errors.FeatureFlagNetworkError-- Thrown when the API is unreachable or returns a non-2xx status.FeatureFlagVerificationError-- Thrown when JWT verification fails (expired, wrong issuer, tampered).
On refresh failure, the client applies exponential backoff starting at 5 seconds, capped at 5 minutes. The onError callback is invoked on every failure. Existing flags remain available during outages, including restored persisted snapshots when the evaluation scope matches exactly.
Browser tab visibility: Polling is automatically paused when the tab is hidden and resumed with an immediate refresh when it becomes visible again. This prevents unnecessary server requests from background tabs.
import { FeatureFlagNetworkError, FeatureFlagVerificationError } from "@mergedapp/feature-flags"
import { createClient } from "./generated/feature-flags"
const client = createClient({
apiUrl: "<coming_soon>",
clientKey: "lk_pub_your_key_here",
organizationId: "org_123",
environmentId: "env_prod",
onError: (error) => {
if (error instanceof FeatureFlagNetworkError) {
console.warn("Flag service unreachable, using cached flags")
}
if (error instanceof FeatureFlagVerificationError) {
console.error("Flag token verification failed", error)
}
},
})Audit & Cleanup
Audit
Scan your codebase for unused or stale flag references:
npx merged-ff audit --api-url=<coming_soon> --client-key=lk_pub_xxx --dir=./srcProduces a report showing active, unused, and archived-but-still-referenced flags.
Cleanup
Automatically replace archived boolean flag checks with false:
# Preview changes without modifying files
npx merged-ff cleanup --api-url=<coming_soon> --client-key=lk_pub_xxx --dry-run
# Apply changes
npx merged-ff cleanup --api-url=<coming_soon> --client-key=lk_pub_xxxAfter cleanup, run merged-ff generate to update the generated file.
API Reference
MergedFeatureFlags<TFlags>
The core client class is generic: MergedFeatureFlags<TFlags extends FlagRegistry = FlagRegistry>. When using generated createClient, the type parameter is pre-filled.
| Method | Returns | Description |
| ----------------------------------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| initialize() | Promise<void> | Fetch public key (if needed), fetch and verify flags |
| isEnabled<K extends string & keyof TFlags>(name: K) | boolean | Check if a flag is enabled (false for unknown flags) |
| getValue<K extends string & keyof TFlags>(name: K) | TFlags[K] \| undefined | Get a flag's value with inferred return type |
| getFlag(idOrName: string) | EvaluatedFlag \| undef | Get the full evaluated flag entry |
| getAllFlags() | EvaluatedFlag[] | Get all evaluated flags |
| refresh() | Promise<void> | Manually trigger a flag refresh from the server |
| getStatus() | FeatureFlagRuntimeStatus | Read snapshot source, staleness, and last refresh metadata |
| setEvaluationContext(context) | void | Replace the caller-provided attribute context for future refreshes; the current snapshot stays active until the next refresh |
| onChange(listener) | () => void | Subscribe to flag changes; returns unsubscribe function |
| destroy() | void | Clean up timers, listeners, and cached data |
React Hooks (Generated)
When using hooks from the generated file (via createTypedHooks<FlagValues>()), all hooks are fully typed:
| Hook | Returns | Description |
| ------------------------ | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
| useFeatureFlag(name) | { enabled: boolean, value: TFlags[K] \| undefined } | Get a single flag's typed state |
| useFeatureFlags() | EvaluatedFlag[] | Get all flags |
| useFeatureFlagClient() | MergedFeatureFlags<TFlags> | Access the underlying typed client |
| useFeatureFlagStatus() | { status, isLoading, isReady, error, source, isStale, lastSuccessfulRefreshAt, tokenExpiresAt } | Read provider initialization and snapshot-source state |
There is no public untyped React hook entrypoint. React applications are expected to consume the generated bindings so invalid flag code keys fail at compile time.
OpenFeature
OpenFeature integrations live on dedicated runtime entrypoints:
@mergedapp/feature-flags/openfeature/web@mergedapp/feature-flags/openfeature/server
Use the web entrypoint with @openfeature/web-sdk and the server entrypoint with @openfeature/server-sdk. The providers support:
- Merged evaluation through OpenFeature
- OpenFeature domains
- server transaction context
- hook helpers via
createLoggingHookandcreateStaticContextHook
OpenFeature hooks are evaluation lifecycle middleware. They let you run shared logic before evaluation, after evaluation, on error, and finally. In this SDK, the bundled helpers are meant for two common cases:
createStaticContextHookto attach shared context once instead of repeating it at every callsitecreateLoggingHookto observe evaluation outcomes and failures consistently
Tracking is not implemented yet.
NestJS
NestJS integration lives at @mergedapp/feature-flags/nestjs.
It exposes:
FeatureFlagsModuleFeatureFlagsServiceRequireFeatureFlagFeatureFlagGateFeatureFlagGuardFeatureFlagContextInterceptorFeatureFlagContext
The Nest module is native-only. It evaluates through the Merged SDK and keeps type-safe decorators, guards, interceptors, and generated bindings aligned with the generated registry.
CLI Commands
| Command | Description |
| ---------- | --------------------------------------------------- |
| generate | Generate typed TypeScript SDK from flag definitions |
| audit | Scan codebase for unused or stale flag references |
| cleanup | Replace archived boolean flag checks with false |
