This FRD defines a shared configuration and environment-variable library that provides Zod-backed env schemas, app-safe and server-safe accessors, secret-safe logging, and a consistent config-loading pattern. The library replaces per-app ad-hoc process.env access with a validated, typed, and documented approach that catches missing or malformed environment variables at startup rather than at runtime.
Introduction
Every application in the platform reads environment variables for configuration: database URLs, API keys, feature flags, service endpoints, log levels. Today each app invents its own config bootstrap: some use raw process.env access with no validation, some use ad-hoc Zod schemas, some use .env files with inconsistent naming conventions. Misconfigured environments cause runtime crashes that are difficult to diagnose. This library standardises config loading so that every app fails fast at startup with a clear error message when configuration is invalid.
Scope
In scope
defineEnvSchema(schema) function that creates a validated, typed env config from a Zod schema.
loadEnv(schema, source?) function that reads from process.env (or a provided source) and validates against the schema.
App-safe accessors: a clientEnv subset that only exposes non-secret values safe for browser bundles.
Server-safe accessors: a serverEnv subset that includes secrets and is only available in server contexts.
Secret-safe logging: logConfig(config) that prints the loaded configuration with secret values masked.
.env file loading via dotenv integration (optional, development only).
Docs covering local, dev, staging, and production configuration patterns.
Environment-specific config files (e.g., config.production.json). The library reads from env vars only.
Users and Pain Points
User
Pain Point
App developer
Accesses process.env.DATABASE_URL with no validation; a typo (DATABSE_URL) causes a runtime crash deep in the database connection code instead of at startup.
App developer
No clear distinction between env vars safe for the browser bundle and server-only secrets. Accidentally exposes an API key in the client bundle.
App developer
Each app has a different .env.example format with inconsistent naming conventions.
DevOps engineer
Deploys a new environment and gets cryptic runtime errors because a required env var is missing. Wants a clear startup error listing all missing variables.
Security reviewer
Finds secrets logged in plaintext during config debugging.
Definitions
Term
Definition
Env schema
A Zod schema that defines the expected environment variables, their types, defaults, and whether they are secret.
Client env
The subset of configuration values that are safe to include in browser bundles (no secrets).
Server env
The full configuration including secrets, only available in server-side code.
Secret
An environment variable whose value must not appear in logs, error messages, or browser bundles. Identified by a .secret() marker in the schema.
Fail-fast
The application exits immediately at startup if required env vars are missing or invalid, rather than failing later at runtime.
Current State
No shared typed runtime config helper exists.
Each app invents its own config bootstrap, typically with raw process.env access.
Some apps use .env files with dotenv; others do not.
No convention for distinguishing client-safe vs. server-only variables.
No convention for masking secrets in log output.
Naming conventions are inconsistent (e.g., DB_URL vs. DATABASE_URL vs. POSTGRES_URL).
Schema definition: defineEnvSchema wraps a Zod object schema with metadata about which keys are secrets. The secret() helper returns the same Zod type but marks it in the schema metadata.
Validation: loadEnv(schema, source?) reads from source (defaults to process.env), strips unknown keys, validates against the schema, and returns the parsed result. If validation fails, it collects all errors and throws EnvValidationError.
Client split: The client property on the returned config is a Proxy (or a pre-filtered plain object) that only exposes non-secret keys. Accessing a secret key on client returns undefined at runtime and is a TypeScript error at compile time.
Secret-safe logging: logConfig iterates over the schema keys, prints non-secret values as-is, and prints secret values as [SECRET]. Output is a formatted string suitable for a startup log line.
Dotenv integration: When NODE_ENV is development and dotenv is installed, loadEnv automatically loads .env, .env.local, and .env.development files (in that order, later files override). In production, .env files are not loaded.
Error formatting: EnvValidationError includes a message with one line per invalid variable, a issues array of { key, message } objects, and the original Zod error for programmatic access.
Convenience booleans: The returned config includes isProduction, isDevelopment, and isStaging computed from the NODE_ENV value (if it exists in the schema).
Non-Functional Requirements
Category
Requirement
Startup time
loadEnv completes in under 5ms for a schema with 30 variables.
Bundle size
The library adds less than 1 KB gzipped beyond Zod itself.
Portability
Works in Node 20+, Deno (via Deno.env), and Cloudflare Workers (via env parameter). loadEnv accepts a source parameter so it is not coupled to process.env.
Security
Secret values are never included in error messages. If a secret fails validation, the error says the variable name and the Zod error message but not the actual value.
Type safety
The return type of loadEnv is fully inferred from the Zod schema. env.client excludes secret keys at the type level.
This is a headless library with no UI. Error messages should be clear and readable in terminal output. No accessibility requirements apply directly.
Content and Documentation Requirements
TSDoc on every exported function and type.
A docs/best-practices/config-and-env.mdx guide covering: defining an env schema, loading config, client/server split, secret masking, dotenv integration for development, production deployment checklist, and testing with custom sources.
Storybook docs page under Docs/Best Practices/Config & Environment.
An .env.example template generator: generateEnvExample(schema) that produces a commented .env.example file from the schema.
Dependencies
Dependency
Type
Notes
zod
Existing
Schema validation and type inference.
dotenv
Peer (optional)
.env file loading in development. Not a runtime dependency in production.
Risks and Tradeoffs
Risk
Likelihood
Impact
Mitigation
secret() marker relies on developer discipline; forgetting to mark a secret exposes it to the client bundle.
Medium
High
Lint rule or code review checklist item. Variable naming convention: secrets do not start with PUBLIC_. The library warns at load time if a non-PUBLIC_ variable is not marked as secret.
Dotenv auto-loading in development may surprise developers who expect env vars from their shell.
Low
Low
Only load .env files when NODE_ENV=development and dotenv is installed. Log a message when files are loaded.
Zod coerce for numbers may silently convert invalid strings to NaN.
Low
Medium
The schema uses z.coerce.number().int().positive() which rejects NaN. Document this pattern.
Some runtimes (Cloudflare Workers) do not have process.env.
Medium
Low
loadEnv accepts a source parameter. Document the Workers pattern: loadEnv(schema, env) where env is the Workers env binding.
Open Questions
Should the library support nested config objects (e.g., database: { url, poolSize }) or keep it flat? Leaning toward flat keys matching env var names, with an optional group() utility for documentation purposes.
Should generateEnvExample(schema) be a runtime export or a CLI command? Leaning toward a runtime export that apps call in a script.
Should the library validate that PUBLIC_* variables are not marked as secrets and vice versa? This would enforce a naming convention but may be too opinionated.
Acceptance Criteria
defineEnvSchema accepts a Zod schema with secret() annotations and returns a typed schema descriptor.
loadEnv validates process.env and returns a fully typed config object.
Missing or invalid env vars cause EnvValidationError with all failures listed (not just the first one).
Secret values never appear in error messages.
env.client excludes secret values at both the type level and runtime.
logConfig prints all values with secrets masked as [SECRET].
z.coerce.number() and z.coerce.boolean() work for numeric and boolean env vars.
Default values from the Zod schema are applied when env vars are missing.
loadEnv(schema, source) accepts a custom source for testing and non-Node runtimes.
isProduction, isDevelopment, isStaging convenience booleans are correct.
Create src/lib/config/ as a new module. Files: index.ts, schema.ts (defineEnvSchema, secret marker), loader.ts (loadEnv), logger.ts (logConfig), errors.ts (EnvValidationError), types.ts (type utilities for client/server split).
The secret() function wraps a Zod schema by attaching a Symbol metadata marker. defineEnvSchema reads this marker to build the secret key set.
loadEnv uses z.object(shape).safeParse(source). On failure, map zodError.issues to { key: issue.path[0], message: issue.message } and throw EnvValidationError.
The client accessor is a plain frozen object created at load time by filtering out secret keys. Do not use a Proxy — a plain object is simpler and produces better error messages.
logConfig iterates over Object.keys(schema), checks the secret set, and formats output. Return a string; the caller can log it however they prefer.
Dotenv integration: check typeof process !== "undefined" && process.env?.NODE_ENV === "development". Dynamically import("dotenv") and call .config(). Catch the import error silently if dotenv is not installed.
Tests go in src/lib/config/*.test.ts. Pass a custom source record to loadEnv instead of mutating process.env.
Run pnpm typecheck and pnpm vitest run --project unit before declaring complete.
Decision Log
Date
Decision
Rationale
2026-05-26
Flat env vars only, no nested config objects.
Env vars are inherently flat. Nested config adds indirection without benefit.
2026-05-26
secret() marker via Symbol metadata, not a naming convention.
Naming conventions are fragile and vary across teams. Explicit marking is safer.
2026-05-26
dotenv is a peer dependency, not bundled.
Production deployments should not depend on .env files. Development-only convenience.
2026-05-26
loadEnv accepts a source parameter for portability.
Supports Cloudflare Workers, Deno, and test environments without process.env.