configuration-management
Use when adding, renaming or reading an environment variable, extending the typed config layer, or diagnosing a build that fails at boot with a schema error. Triggers include "add an env var", reading
SKILL.mdplugins/react-frontend-sdlc/skills/configuration-management/SKILL.md
GitHubConfiguration management
Profile keys consumed
make.formatmake.lint_eslintmake.lint_tscarchitecture.source_root
Overview
Environment variables are read once, through a typed and validated config module, and never from
process.env at the point of use. Validation runs at module load, so a missing or malformed value
fails the build immediately instead of degrading silently in production.
When to use
- Adding, renaming or removing an environment variable.
- Reading configuration from application code, a component, or a hook.
- ESLint rejects a
process.envmember expression. - A build or dev server dies at boot with a schema error naming one variable.
- Reviewing whether a value belongs in the build-time layer or the runtime layer.
- Not for: secrets management, CI workflow
env:blocks, or Docker Compose variables that never reach application code.
Applicability by repository shape
- React SPA shape (feature modules under the source root, a bootable app, an aggregate CI
target): partial — the layer lives under the source root (
architecture.source_root) atconfig/env/(dependency-freeraw-env.tsfor the paint path plus Zod-validatedenv.ts, frozen at construction), the ban is scoped to non-React.tsfiles under the source root and runs under the target mapped bymake.lint_eslint; a dedicated env-sync target asserts.envand.env.exampledeclare the same keys, andconfig/runtime/under the same source root carries values an operator can change without a rebuild. - Next.js app shape (routed pages, no aggregate duplication gate): yes —
src/config/env.tsis the single reader, enforced byno-restricted-syntaxunder the target mapped bymake.lint_eslint, over layered.env,.env.productionand.env.example. - Component-library shape (Storybook-first, no bootable app, published package): no — components
read
process.envdirectly; no barrel exists.
The env-sync target has no logical key in the profile; in the React SPA shape it is the repository's
own .env/.env.example key-parity check:
make check-env-sync # asserts .env and .env.example declare the same keys
Core pattern
// Correct — the validated module is the only source. This is the Next.js shape's barrel, which
// exports the parsed object directly; the React SPA shape's `config/env/` under the source root
// exports a frozen singleton read through accessors instead.
import { env } from '@/config/env';
const apiUrl = env.NEXT_PUBLIC_GRAPHQL_API_URL;
// Rejected by ESLint everywhere except the config module itself.
const bad = process.env.NEXT_PUBLIC_GRAPHQL_API_URL;
The rule matches both spellings — MemberExpression[object.name='process'][property.name='env'] for
process.env.X and [property.value='env'] for the computed process['env'] — because a
dotted-only selector is trivially sidestepped. The config module, tests, and stories are the only
exemptions.
Procedure
- Declare the variable in
.env.examplewith a comment saying what it is for. - Add it to the schema with a type that actually validates: a URL check, a coerced number, an enum, or a non-empty string — not a bare string.
- Set the value in every layered file that needs it (
.envfor development, the production overlay for the deployed build). - Read it from the config module at every call site.
- Run the formatter mapped by
make.format, then the gates mapped bymake.lint_eslintandmake.lint_tsc— skip either with a recorded note when it maps tonull.
Validation is a deployment blocker
Parsing happens at module load, before any application code runs, and a failure throws with the variable name and the reason. Treat that as correct behaviour: a bad configuration is not a runtime condition to handle, and defaulting past it ships a silent no-op — a placeholder analytics id, or a development endpoint in production.
Two invariants worth keeping when the schema grows: endpoints that carry credentials or trace
headers should be required to be encrypted, accepting cleartext only for loopback hosts; and in a
statically exported build, public variables are inlined only for literal member expressions, so
a dynamic read (process.env[name]) inlines undefined in the browser bundle.
Common mistakes
- Reading
process.env"just this once" in a component — refactor the read into the config module rather than reaching for a lint suppression, which these repository shapes forbid. - Typing everything as
z.string()— the schema then validates nothing. - Adding the variable to the schema but not to
.env.example— the next contributor's build fails at boot with no template to copy from. - Committing a local override file — those are ignored on purpose; personal values belong there, shared defaults in the tracked layer.