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 FeatureGuard, kill_<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:
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 |
|
|
Debugging | Development-only debug panel | Development-only |
Expiry enforcement | Local script, not enforced in CI | Not implemented |
Kill-switch model | Optional metadata relationship, currently unused |
|
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:
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_FLAGScontains a general JSON map.NEXT_PUBLIC_FEATURE_FLAGS_ENVcontains 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 | Feature disabled with source |
Current environment has an override | Environment value with source |
General configuration has an override | General value with source |
No override exists | Catalog |
Requested key is unknown | Disabled with source |
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:
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:
- Expiry metadata makes stale flags identifiable.
- A cleanup command makes that debt inspectable.
- CI enforcement makes the result difficult to ignore.
- 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 |
|
|
Debugging | Development-only debug panel | Development-only |
Expiry enforcement | Local script, not enforced in CI | Not implemented |
Kill-switch model | Optional metadata relationship, currently unused |
|
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.
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.