Flags Without a Dashboard
Everything on this page is verified against
lib/featureFlags.ts,
pages/api/feature-flags.ts,
stores/featureFlagStore.ts
and
context/ConfigContext.tsx.
Sooner or later you'll want to ship a feature dark, and you'll go looking for the flag
dashboard. Save yourself the search: there isn't one. No LaunchDarkly, no Azure App
Configuration client in the browser — the entire system is one TypeScript interface, one BFF
route, one Zustand store, and four hooks. Flags come from the backend's own
/FeatureFlags endpoint, and everything else is merging. Which means the only thing you
actually need to learn is the merge order — and it fits in nine lines.
The four moving parts
| Piece | File | Role |
|---|---|---|
FeatureFlagNames | lib/featureFlags.ts | The closed set of known flag keys. 28 optional booleans. Adding a flag means adding a key here — keyof FeatureFlagNames is what makes the hooks type-safe |
defaultFlags | lib/featureFlags.ts | The floor. Only five keys, all explicitly false |
applyRules(flags, env, isLocalhost) | lib/featureFlags.ts | The ceiling. Local-development overrides, applied last |
/api/feature-flags | pages/api/feature-flags.ts | Does the merge, server-side, once per page load |
Precedence, exactly
The whole resolution is nine lines in the BFF route:
const rawFlags: FeatureFlagResponse[] =
(await callBackend<FeatureFlagResponse[]>('/FeatureFlags', { ... })) || [];
const externalFlags: FeatureFlagNames = {};
rawFlags.forEach(flag => {
externalFlags[flag.flagName as keyof FeatureFlagNames] = flag.isEnabled;
});
let flags = { ...defaultFlags, ...externalFlags };
const host = req.headers.host || '';
const env = getEnvironment(host);
const isLocalhost = host.includes('localhost') || host.includes('192.168.');
flags = applyRules(flags, env, isLocalhost);
So, from lowest to highest:
defaultFlags— the safety floor. Five keys today, every one of themfalse:enable-camera-drag-pan,enable-pano-degree-offset,enable-peakfinder,enable-pano-freshness-badge,enable-camera-area-filter. Each has a comment in the source explaining why it is off; they are all staged rollouts the backend is expected to turn on. The remaining 23 keys inFeatureFlagNameshave no default at all — absent from the response meansundefined, which every consumer treats as off.- Backend flags —
{ ...defaultFlags, ...externalFlags }. A backend flag beats a default, including when the backend saysfalse. isLocalhost— the only ruleapplyRulesactually implements, and it wins over everything. On a localhost or192.168.host it force-sets seven flags totrue:enable-debug,enable-offline-button,enable-ai-detection-sound,enable-pano-degree-offset,enable-peakfinder,enable-pano-freshness-badge,enable-camera-area-filter. A backendfalsefor any of those seven is discarded locally.
env is accepted and never usedapplyRules(flags, env, isLocalhost) takes an env parameter, and the route computes it
with getEnvironment(host) — but the function body never reads it. There are no
environment-scoped overrides. dev, uat and prd all get exactly what the backend
sends, plus nothing. If you are looking for "why isn't this flag on in dev", the answer is
the backend's /FeatureFlags response, not this file. The parameter is a placeholder for a
rule set that does not exist yet; getEnvironment itself works and is unit-tested, it is
simply not wired to anything.
applyRules also mutates the object it is handed and returns the same reference. That
is harmless where it is called — both call sites pass a freshly spread object — but do not
assume it is pure.
From the route to a component
ConfigProvider fetches /api/env and /api/feature-flags in parallel on mount and
renders a <Loader /> until both settle:
const [configResponse, featureFlagsResponse] = await Promise.all([
fetch('/api/env'),
fetch('/api/feature-flags'),
]);
A non-OK feature-flags response is logged and swallowed — only /api/env failing throws.
The provider sits above AuthGuard in app/layout.tsx, so no component ever renders
before the flags are in the store; there is no "flag flicker" to design around.
useFeatureFlagStore is persist(devtools(...)) — the flags are written to
localStorage under the key feature-flags-storage, and the initial state is
{ ...defaultFlags } before rehydration. setFlags merges rather than replaces:
setFlags: (flags) => set({ flags: { ...get().flags, ...flags }, isLoaded: true }),
Two consequences worth internalising:
- Persisted flags outlive a deployment. A returning user starts on last session's values
until
/api/feature-flagsresolves. Because the route always returns the full merged set, every key it knows about gets overwritten — but a flag key the route stops returning keeps its stale persisted value forever, until the user clears site data orreset()runs. isLoadedis set bysetFlags, not by rehydration. It means "the network response landed", so it distinguishes real flags from persisted/default ones.
Four read hooks, all in featureFlagStore.ts:
| Hook / method | Returns |
|---|---|
useIsFeatureEnabled(name) | boolean — !!flags[name], the one to use for gating |
useFeatureFlag<T>(name) | The raw value, undefined if unset — use when "off" and "unset" differ |
useAllFeatureFlags() | The whole object |
useFeatureFlagStore.getState().getFlag(name) | Non-reactive read, for use outside React |
enable-all-features-connected-to-signalr
This is the flag that matters most, because it is not a UI toggle — it is the master switch for the entire real-time subsystem.
The gate is the first statement in signalRStore.initializeConnection:
const isSignalREnabled = useFeatureFlagStore
.getState()
.getFlag('enable-all-features-connected-to-signalr');
if (!isSignalREnabled) return;
It returns before HubConnectionBuilder runs, so with the flag off there is no
connection, no JoinGroup, and none of the five broadcast handlers are ever registered.
Every downstream consumer of live data — new camera images, panoramas, activity-log entries,
the lease countdown, AI detections — is dark. The fan-out those handlers drive is documented
in deep-dives/signalr-realtime-architecture.
Because the app cannot simply stop working, three surfaces read the same flag and switch to polling or to a static presentation:
| Consumer | With the flag on | With it off |
|---|---|---|
TileViewPage | Joins the camera group for primaryCamera and drives loading state off SignalR | if (!isSignalREnabled || !connection) return; — no group join; setIsLoading(false) is driven by the TanStack Query callbacks instead |
PanoImage | refetchInterval: false — the query never polls; mode="live" on the freshness badge | refetchInterval: 10000 — polls every 10 s; mode="polling" |
CameraImageWrapper | Renders the SignalR-fed image branch | Renders the polled branch (both branches additionally require offlineTier < OfflineTier.OFFLINE_7D) |
| Lease countdown | ReceiveCurrentConfigBroadcast drives secondsRemainingInLeaseMode | The countdown never leaves 0; PTZ still works over plain HTTP. See deep-dives/camera-lease-concurrency |
defaultFlagsIt is not one of the five defaulted keys, so if the backend does not return it — or if
/FeatureFlags fails and the fallback path runs — it resolves to undefined, which is
falsy, and the entire SignalR subsystem stays off. That is the safe direction, but it
means a backend blip presents to operators as "live updates stopped working" rather than as
an error.
Adding a flag
- Add the key to
FeatureFlagNamesinlib/featureFlags.ts. Without this,getFlaganduseIsFeatureEnabledwill not type-check against it. - Decide whether it needs a
defaultFlagsentry. It does if the feature hides or removes something — the comment onenable-camera-area-filteris the reference case: a filter that hides cameras must be off until the backend explicitly enables it. A purely additive feature can be left undefaulted and rely onundefinedbeing falsy. - If local development should always see it, add a line to
applyRulesunder theisLocalhostbranch — and remember this overrides the backend, so do not put anything there whose "off" behavior you need to test locally. - Register the flag on the backend so
/FeatureFlagsreturns it. Until then, only the default and the localhost rule apply. - Gate the UI with
useIsFeatureEnabled('your-flag').
lib/featureFlags.test.ts covers the precedence rules directly — including
applyRules({ 'enable-360': false }, 'dev', true) proving that backend values survive the
localhost pass for keys the localhost rules do not touch. Extend it when you add a rule.
Debugging checklist
- A flag is on locally but off in dev/uat/prd — it is almost certainly one of the seven
isLocalhostoverrides. Compare against the list above before assuming a deployment problem. - A flag is off everywhere including localhost — check the raw response:
GET /api/feature-flagsin the network tab returns the fully merged object, which is the single source of truth for what the browser was told. - The value in the app disagrees with
/api/feature-flags— you are looking at a persisted value inlocalStorageunderfeature-flags-storage, or at a flag key that the route no longer returns. Clear site data to confirm. - Everything real-time stopped — check
enable-all-features-connected-to-signalrfirst, then whether/FeatureFlagsitself is failing (the fallback path logsError fetching feature flags, using fallbackand still returns200). - A
-devdeployment is not getting the localhost overrides — that is correct behavior;isLocalhostandgetEnvironment(host) === 'dev'are not the same test.
Related
deep-dives/signalr-realtime-architecture— whatenable-all-features-connected-to-signalrswitches on: the connection, the group membership model, and the five broadcast handlers.deep-dives/camera-lease-concurrency— the lease countdown is one of the features that goes dark when that flag is off.security/authentication— why/api/feature-flagsis left out of theproxy.tsmatcher and runs unauthenticated.state-management/stores-overview— wherefeatureFlagStoresits among the app's Zustand stores.