23. Serve browser-facing config at request time instead of inlining it
Date: 2026-09-01
Status
Accepted for the portal's browser-facing configuration, including the state it serves.
Context
Next.js replaces every process.env.NEXT_PUBLIC_* reference with a literal during next build. The value is therefore fixed by whichever environment ran the build. Setting the same variable later — on the running container, or in the IIS web.config <environmentVariables> block — cannot change what the browser receives, because the browser is reading a string that was already written into a static chunk.
The portal carried nine such values: Google Analytics, Amplitude, Mixpanel, SiteImprove, both Socure SDK keys, the Smarty embeddable key, and two development toggles. All nine were threaded through --build-arg in deploy-ecr.yaml, ARG lines in the Dockerfile, and a build-step env: block in release-iis-dc.yaml.
Two consequences followed. One artifact could not be promoted across environments, because each environment needed its own build — breaking twelve-factor Config (III) and Build, release, run (V). And the failure was silent: an unset build arg inlined an empty string, so the feature simply did nothing in the deployed environment with no error.
The middleware in proxy.ts made this sharper. It gates CSP connect-src/script-src entries on whether an analytics key is present. Because those reads were inlined too, a key supplied at runtime would have been blocked by a Content-Security-Policy frozen at build time — a second, less obvious way for the same misconfiguration to fail.
Decision
Drop the
NEXT_PUBLIC_prefix from browser-facing config. Unprefixed names are never inlined, so they stay in the server process environment.lib/runtime-config.tsreads them and returns a typedRuntimeConfig.Resolve per request in the root layout, hand off through context.
layout.tsxcallsgetRuntimeConfig()and wraps the tree inRuntimeConfigProvider; client components read it withuseRuntimeConfig(). The values ride the RSC payload, so there is no extra round-trip and no render before config is known — analytics initializes on first paint rather than after a fetch settles.This costs nothing in prerendering:
generateMetadataalready awaitsheaders(), so every real route was already dynamically rendered. Only_global-errorandfavicon.icoare prerendered, and neither reads config.useRuntimeConfig()returns an empty config outside the provider rather than throwing, matchinguseFeatureFlag(). Every integration is optional, so "no config" is a legitimate state — an absent key already means "this integration is off" — and components stay renderable in isolation.CSP is computed from the same unprefixed variables. The middleware runs per request, so adding a key at release time widens the policy without a rebuild.
Config is no longer a build input. The Dockerfile
ARGs, the--build-argflags indeploy-ecr.yaml, and the build-stepenv:entries inrelease-iis-dc.yamlare gone. Only build-identity values (NEXT_PUBLIC_BUILD_SHA,NEXT_PUBLIC_DC_CONNECTOR_SHA) stay inlined, which is correct — they describe the artifact, not the environment.The state moves to runtime too, which needed the theming to change.
STATEis not merely a value: it selected the compiled USWDS stylesheet, thenext/fontfaces, and the locale bundle. Sass configuresuswds-coreonce per compilation —@use ... with (...)is singleton — so two themes cannot coexist in one stylesheet. Each state is therefore compiled to its own file (public/themes/theme-{state}.css, ~630 KB each) and linked onceSTATEis known at request time, so a visitor downloads one theme rather than every state's. Fonts for every state are declared in one generated module withpreloadoff, since preloading is a build-time hint and would otherwise fetch faces this process will never render. Locale bundles already carried every state.Client components read the state from the DOM.
getState()resolves<html data-state>first, thenprocess.env.STATE, thenNEXT_PUBLIC_STATE. The attribute is the browser's only runtime source; the server stamps it per request so both sides agree and hydration stays stable. Keeping the function's signature meant none of its 27 call sites changed. TheNEXT_PUBLIC_STATEfallback remains for the enrollment checker, which deploys one static export per state and has no server to read env from.The portal image is state-agnostic. The Dockerfile no longer takes
ARG STATE, and neither ECR build passes it. The image is identical for every state and can be promoted between them; the ECS task definition suppliesSTATE. (Each environment still pushes to its own ECR repository — consolidating those is infrastructure work outside this repo.)
Consequences
- One frontend artifact runs in any environment and as any state. Verified: an artifact built with no client config and no
STATEservedG-RUNTIME-AAA/smarty-aaathenG-RUNTIME-BBB/smarty-bbb/amp-bbb(CSP widening for Amplitude only on the second run), and the same artifact serveddata-state="dc"withtheme-dc.cssand the DC title, thendata-state="co"withtheme-co.cssand the CO title, purely fromSTATE. - The USWDS theme is no longer part of the JS bundle's CSS; it is a static stylesheet linked at request time. Two consequences were closed rather than accepted: the link carries
?v=<build sha>because the file has no content hash to bust caches on deploy, and the fonts moved out ofnext/fontinto@font-facerules inside each per-state stylesheet, with the layout preloading exactly that state's files from a generated manifest. Declaring every state's faces throughnext/fontwould have meant either preloading typefaces the process never renders, or no preloading at all. Nothing is lost by the move: the files were already self-hosted at stable paths underpublic/fonts, andadjustFontFallbackwas already disabled.optimizeCsswas never enabled, so no critical-CSS inlining existed to lose. STATEwas a required build input, so a missing value used to break the build. It is now supplied per deployment, andinstrumentation.tsrefuses to start in production without it — a misconfigured environment fails loudly at deploy instead of quietly serving DC branding.- Deployment coordination is required before this reaches production. The values must now be set on the running container (Tofu / ECS task definition) for the Docker path, and in
web.config's<environmentVariables>on the host for the IIS path. Removing the build args without that leaves analytics, Socure, and Smarty switched off in deployed environments — the same silent failure as before, in the opposite direction. - The
web.configruntime env block is now load-bearing rather than inert. CLAUDE.md previously (and correctly) warned that it had no effect on browser code; that guidance is superseded for these variables. - Tests that need a vendor key enabled supply it through
RuntimeConfigProviderinstead of stubbingprocess.env. Omitting the wrapper is how a test expresses "not configured", which removed somevi.resetModules()re-import workarounds that existed only because the key was read at module scope.
References
apps/portal/src/SEBT.Portal.Web/src/lib/runtime-config.ts— the server-side reader.apps/portal/src/SEBT.Portal.Web/src/providers/RuntimeConfigProvider.tsx— context and hook.apps/portal/src/SEBT.Portal.Web/src/proxy.ts— CSP built from the same variables.scripts/ci/templates/web.config— the IIS runtime environment block.