Skip to main content

← Blog

Governed Client-Side Feature Flags Without LaunchDarkly

Typed feature flags with owners, expiry checks, explicit precedence, and deployment-bound overrides, using patterns from GitMyABI and Atlas.

Published
May 15, 2026
Updated
Updated July 13, 2026
Author
Daniel Mark
Reading time
13 min read

Feature flags are easy to introduce and painful to remove.

A teammate adds NEXT_PUBLIC_SHOW_NEW_LOGS=true for an experiment. Six months later, nobody remembers who owns it, whether production still depends on it, or what a safe rollback looks like. The temporary switch has quietly become part of the architecture.

That is the governance problem a file-backed flag catalog can solve. It cannot provide percentage rollouts, product-manager self-service, or live targeting without deployments. What it can provide is reviewable ownership, explicit defaults, documented precedence, and a path toward eventual removal.

I contributed frontend work on GitMyABI, AxLabs GmbH's smart contract developer platform, where the frontend contains a typed feature-flag catalog with metadata, evaluation helpers, and cleanup checks. Atlas, my platform monorepo, encodes a different set of conventions through FeatureGuardkill_<feature> naming, and development inspection routes.

Both implementations address the same class of problem. They optimize for different goals, and neither replaces server authorization, experiment analytics, or a managed rollout platform such as LaunchDarkly.

What this system is designed to govern

The flags in these repositories govern client-side UX and application behavior:

  • Which logs viewer renders
  • Whether React Query continues polling
  • Which paywall copy appears
  • Whether a development inspection panel is available

They are not entitlement systems.

GitMyABI has separate hooks such as useFeatureLimit and useCanCreateProject that read team subscription data and decide what the frontend should display. A paywall can appear because the current team has reached its project limit, while a feature flag independently changes the wording inside that paywall.

Even those subscription hooks remain client-side UX controls. They help the interface explain what the user can do, but they are not a substitute for backend authorization. The API must independently enforce paid capabilities and other sensitive operations.

The distinction is important:

  • A feature flag answers, "Which client behavior should this deployment use?"
  • A frontend entitlement hook answers, "What does the interface believe this plan allows?"
  • Server authorization answers, "May this operation actually happen?"

Conflating those responsibilities turns temporary rollout keys into permanent access-control machinery.

The typed GitMyABI catalog

GitMyABI keeps six feature flags in src/lib/feature-flags/flags.ts. The flag names form a FeatureFlagKey union, while each catalog entry follows a FeatureFlagMetadata interface:

1
export type FeatureFlagKey =
2
| 'logs_viewer_v2'
3
| 'projects_wizard_v2'
4
| 'artifact_downloads_ui_v2'
5
| 'reduce_build_polling'
6
| 'paywall_copy_refresh'
7
| 'debug_flags_panel';
8
9
export interface FeatureFlagMetadata {
10
key: FeatureFlagKey;
11
description: string;
12
owner: string;
13
defaultValue: boolean;
14
expiresAt: string;
15
tags?: string[];
16
killSwitchKey?: FeatureFlagKey;
17
}
18
19
export const FEATURE_FLAGS: Record<
20
FeatureFlagKey,
21
FeatureFlagMetadata
22
> = {
23
// Catalog entries
24
};

The union prevents call sites from inventing arbitrary flag names. useFeatureFlag('logs_viewer_v2') type-checks. A misspelled or unknown literal does not.

Typing the catalog as Record<FeatureFlagKey, FeatureFlagMetadata> also makes omissions visible during compilation. Every union member must have a corresponding metadata entry, and every entry must include the required fields.

That does not make the metadata semantically valid. TypeScript can require an expiresAt string, but it cannot prove that the string contains a valid date. It can require an owner, but it cannot prove that the owner still exists or accepts responsibility for cleanup. Those checks depend on repository tooling and code review.

The reviewed catalog has four flags referenced by application code. Three affect product behavior, while one controls a development-only debugging panel. Two other entries exist only in the catalog, documentation, and tests.

Concern

GitMyABI

Atlas

Primary purpose

Operate flags inside a product

Teach repeatable platform conventions

Catalog

Typed metadata with owner, expiry and tags

Typed keys without per-flag governance metadata

Evaluation

Environment, configuration and default precedence

Adapter snapshot with kill switch first

Overrides

Public JSON environment variables

Server environment flags and development overrides

React integration

useFeatureFlag, FeatureFlag, FeatureFlagGuard

useFlag, useFlags, FeatureGuard

Debugging

Development-only debug panel

Development-only /__flags route

Expiry enforcement

Local script, not enforced in CI

Not implemented

Kill-switch model

Optional metadata relationship, currently unused

kill_<feature> naming used by demos

Checked-in consumers

Three product-facing consumers and one development panel

Demo and development routes

The optional killSwitchKey can point to another catalog entry whose enabled state disables the original feature. The evaluator supports that relationship, but no entry in the reviewed catalog currently sets it.

This is an important distinction. The catalog describes a kill-switch capability, but the product does not yet exercise that capability through a real catalog entry.

One flag from catalog to call site

reduce_build_polling is the clearest end-to-end example because it changes application behavior rather than simply selecting a component.

The catalog entry defaults to false and describes an optimization for builds that already receive WebSocket updates. Its purpose is to stop React Query from polling once another mechanism can keep build state current.

The useFeatureFlag hook constructs an evaluation context and calls evaluateFeatureFlag. Without an override, reduce_build_polling resolves to its catalog default.

useGetBuilds then uses the result to configure React Query:

1
const { enabled: reducePolling } = useFeatureFlag(
2
'reduce_build_polling'
3
);
4
5
const pollingInterval = reducePolling ? false : 1000 * 30;
6
7
return useQuery({
8
// ...
9
refetchInterval: pollingInterval,
10
});

When the flag is disabled, React Query polls every thirty seconds.

When it is enabled, polling stops entirely. It does not change to a longer interval. WebSocket-driven updates and manual refetches are expected to keep the interface current instead.

The fallback is deliberately conservative. If no override exists, the flag remains off and polling continues. The application performs more requests, but build information still refreshes.

The flag also has an expiry date, but expiry does not change its runtime behavior. An expired flag continues to evaluate normally until someone removes it or changes its configuration.

That separation is useful. Runtime evaluation answers what the application should do now. Governance tooling answers whether the flag should still exist at all.

Evaluation precedence and failure behavior

GitMyABI loads overrides from two public environment variables:

  • NEXT_PUBLIC_FEATURE_FLAGS contains a general JSON map.
  • NEXT_PUBLIC_FEATURE_FLAGS_ENV contains environment-specific overrides.

The configuration loader parses those values and caches the result. evaluation.ts then applies a documented precedence order.

Condition

Result

Configured kill switch evaluates to true

Feature disabled with source kill-switch

Current environment has an override

Environment value with source env-override

General configuration has an override

General value with source config

No override exists

Catalog defaultValue with source default

Requested key is unknown

Disabled with source unknown

Kill-switch evaluation uses a simpler resolver that checks environment overrides, general overrides, and the switch's default value. It does not recursively follow another killSwitchKey.

That avoids recursive evaluation, but it also means nested kill-switch chains are not supported. A switch is resolved as a simple flag.

Other failure cases are handled independently:

  • Malformed JSON produces a warning and an empty override object.
  • Non-boolean values are ignored during parsing.
  • Missing overrides fall through to the catalog default.
  • Unknown configuration keys remain unused unless they match a valid flag requested by application code.
  • Unknown flag requests resolve to disabled.

This is more precise than saying the entire system "fails closed." Unknown keys do fail closed, but malformed configuration falls back to each flag's default. A flag whose default is true would therefore remain enabled after a parsing failure.

The evaluation context includes fields for future targeting, but the current evaluator does not use subscription tier or project data. The hook's tier value should not be mistaken for a working audience-targeting system.

Why the kill switch is deployment-bound

A client flag backed by NEXT_PUBLIC_* environment variables should not be described as an instantaneous remote kill switch.

Next.js embeds public environment values into the browser bundle during next build. Changing one of these overrides therefore requires changing the deployment configuration and producing a new frontend deployment.

An operator can disable a flag through configuration:

1
NEXT_PUBLIC_FEATURE_FLAGS='{"logs_viewer_v2":false}'

But users do not receive that value until they load the newly deployed bundle. Someone running an older bundle may temporarily retain the previous behavior.

The accurate description is a deployment-controlled emergency disable path. It provides clearer naming and precedence than an isolated boolean, but it does not provide runtime control independent of the release pipeline.

Atlas uses a different configuration split. Server-side flags map from FEATURE_* and KILL_* environment variables. Its client provider reads useConfig().features, while the reviewed client configuration currently initializes those features as an empty object. Development overrides provide the practical client-side inspection path.

Atlas kill switches use the kill_<feature> naming convention. resolveFlag checks the corresponding kill switch before returning the normal feature value.

Neither repository fetches live flag configuration from a remote service. That is not necessarily a defect. It is a constraint that should shape how the system is described and where it is used.

Expiry metadata versus enforcement

Expiry is the governance feature most likely to be confused with completed governance.

Every GitMyABI catalog entry contains an expiresAt value. The cleanup script behind pnpm check:flags inspects that metadata, reports flags approaching expiry, and fails when validation errors are present.

The script exits non-zero on failure. A --warn option reports the same problems without failing the command.

That sounds enforceable, but the script is not currently part of the reviewed GitHub Actions workflow or Git hooks. Engineers can run the check locally, but the repository does not automatically require it before merging.

The reviewed snapshot also contains several flags whose expiry dates have passed. The metadata has made that debt detectable, but it has not removed the debt.

The complete governance loop has four parts:

  1. Expiry metadata makes stale flags identifiable.
  2. A cleanup command makes that debt inspectable.
  3. CI enforcement makes the result difficult to ignore.
  4. An accountable owner removes the flag and its obsolete code paths.

GitMyABI implements the first two. The remaining steps still depend on team process.

Atlas takes a lighter approach. It defines flag names and evaluation conventions but does not attach expiry metadata or provide an equivalent cleanup check.

GitMyABI and Atlas optimize for different things

Concern

GitMyABI

Atlas

Primary purpose

Operate flags inside a product

Teach repeatable platform conventions

Catalog

Typed metadata with owner, expiry and tags

Typed keys without per-flag governance metadata

Evaluation

Environment, configuration and default precedence

Adapter snapshot with kill switch first

Overrides

Public JSON environment variables

Server environment flags and development overrides

React integration

useFeatureFlag, FeatureFlag, FeatureFlagGuard

useFlag, useFlags, FeatureGuard

Debugging

Development-only debug panel

Development-only /__flags route

Expiry enforcement

Local script, not enforced in CI

Not implemented

Kill-switch model

Optional metadata relationship, currently unused

kill_<feature> naming used by demos

Checked-in consumers

Three product-facing consumers and one development panel

Demo and development routes

Atlas adds a FeatureGuard with a separate killedFallback, allowing a caller to distinguish between an ordinary disabled flag and an explicit kill switch.

Its /__flags route calls notFound() outside development. GitMyABI's debug panel similarly returns nothing unless the current environment is development and debug_flags_panel is enabled.

Atlas also has a /demo/flags route that teaches the pattern. That route demonstrates the platform convention; it should not be treated as evidence that Atlas flags are operating production features.

A shared package would be premature while the repositories assign different semantics and responsibilities to their flags. GitMyABI needs operational metadata around a product. Atlas needs a convention that downstream applications can understand and adapt.

Similar syntax does not automatically mean identical architecture.

Three boundaries

Authorization

A client-side feature flag is not authorization.

GitMyABI's subscription hooks help the interface decide when to display limits or a paywall. A copy flag can change "Upgrade Now" to "View Plans," but it cannot change the underlying subscription or grant an API capability.

The backend must enforce sensitive and paid operations independently. A user can inspect client state, alter browser code, or call an API without using the intended interface.

Visibility

Client-side flags are observable.

Values stored in NEXT_PUBLIC_* variables ship to the browser. Hiding a component does not make its implementation secret, and selecting one component at runtime does not necessarily remove the other from the bundle.

The logs interface statically imports both viewer implementations. The feature flag decides which one renders, but both can still ship unless code splitting is introduced separately.

Feature evaluation and bundle splitting solve different problems.

Rollout infrastructure

A file-backed catalog supports environment-wide behavior reviewed through pull requests. It does not automatically provide percentage exposure, tenant targeting, audit trails, live configuration, or experiment analysis.

That is the boundary where managed rollout platforms become useful.

When a file-backed catalog is sufficient

A repository catalog is reasonable when:

  • The number of flags remains small.
  • Engineers own flag changes.
  • Code review is the desired approval workflow.
  • Deployment-bound changes are acceptable.
  • Global or environment-specific behavior is sufficient.
  • Experiment analytics and cohort targeting are unnecessary.

That matches the checked-in GitMyABI implementation: a small catalog, a handful of consumers, and environment-based JSON overrides.

The system remains understandable because an engineer can inspect one catalog, trace a flag to its consumers, and review changes alongside the code they affect.

When a managed rollout platform becomes justified

A platform such as LaunchDarkly becomes more appropriate when:

  • Flags must change without rebuilding the frontend.
  • Product or operations teams need direct control.
  • Percentage, user, tenant, or cohort targeting is required.
  • Audit logs and approval workflows matter.
  • Experiment exposure and outcomes must be measured.
  • Flag volume across multiple applications makes repository-local governance impractical.

The local implementations described here do not offer equivalent capabilities. They solve a smaller problem with less operational machinery.

Comparing them on cost alone misses the architectural decision. The real question is whether the team needs code-reviewed deployment configuration or a separately operated control plane.

What remains incomplete

The reviewed implementations still have four meaningful gaps.

First, GitMyABI's expiry check exists but is not enforced in CI. Stale flags can remain in the catalog without blocking a pull request.

Second, expired and unused entries still require deliberate removal. Metadata identifies the debt but does not delete branches, tests, configuration, or documentation.

Third, GitMyABI implements kill-switch precedence without a catalog entry currently exercising it. The abstraction exists before the operational use case.

Fourth, Atlas demonstrates flag conventions, but its server and client configuration paths are not yet a complete runtime bridge. Its value is presently strongest as a teachable platform pattern.

These limitations do not make the catalogs useless. They define what the systems can honestly claim.

Closing lesson

Feature flags are easy to add and hard to delete. A typed catalog turns scattered environment booleans into reviewable artifacts with known keys, owners, defaults, and explicit precedence.

That is worth doing before another NEXT_PUBLIC_* experiment ships.

It is not the whole governance loop. Enforcement still matters: CI that reports expired flags, owners who remove dead paths, APIs that do not trust client visibility, and accurate language about what a kill switch means when its value is embedded during a build.

File-backed flags and managed rollout platforms solve different problems. One makes a small set of deployment-controlled decisions reviewable. The other makes runtime, targeted rollout operable.

Knowing which problem you have prevents you from building a catalog that pretends to be a control plane, or buying a control plane because nobody wrote down who owns a boolean.


Portrait of Daniel Mark

Written by

Daniel Mark

Senior Frontend Engineer

Daniel Mark is a senior frontend engineer and product consultant with 7+ years of experience building resilient product interfaces for Web3, SaaS, and enterprise teams. He writes about frontend architecture, design systems, performance, SEO, and the engineering decisions that turn complex systems into dependable user experiences.