Here is a bug that has shipped in more codebases than anyone will admit. Someone reads a config value with process.env.STRIPE_KEY!, the exclamation mark tells TypeScript to trust that it exists, and it does — in every environment except the one where a deploy forgot to set it. The app boots fine, serves traffic, and then falls over the first time that code path runs, in production, with a stack trace that points at a line that looks perfectly correct.
The root problem is that process.env is typed as Record<string, string | undefined>, and every non-null assertion is a small lie you are telling the compiler. The fix is to stop trusting process.env directly and validate it once, at startup, against a schema.
Validate at boot, not at use
The pattern is one module that parses the environment when it loads and either hands back a fully typed object or crashes immediately with a clear message. If configuration is wrong, you want to know at boot — in the deploy logs — not three hours later when a user hits the one endpoint that reads that variable.
env.ts — validate once, export a typed object
import { z } from 'zod';
const schema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_KEY: z.string().min(1),
PORT: z.coerce.number().int().default(3000),
NODE_ENV: z.enum(['development', 'test', 'production']),
});
const parsed = schema.safeParse(process.env);
if (!parsed.success) {
console.error('Invalid environment:', z.treeifyError(parsed.error));
process.exit(1);
}
export const env = parsed.data;
export type Env = z.infer<typeof schema>;
Now everywhere else imports env, not process.env. env.PORT is a number, not a string you have to remember to parse. env.NODE_ENV is a union, so a typo like 'prodcution' is a type error. And a missing STRIPE_KEY stops the process at line one with a message that names the variable, instead of a null-reference deep in a request handler.
One source of truth in a monorepo
In a monorepo the temptation is to redo this per app, which means two schemas that drift. Put the schema in a shared package instead — a small @acme/env that every app imports — so the web app and the API agree on exactly what a valid environment looks like. When you add a variable, you add it in one place and every consumer gets the type immediately.
Not every app needs every variable, so a shared package can export a base schema that apps extend with their own required keys. The point is that the definition lives once; extension is additive, not a copy-paste.
Client versus server in Next.js
Next.js adds a wrinkle that bites people: any variable prefixed NEXT_PUBLIC_ is inlined into the client bundle and is therefore public. It is visible to anyone who opens dev tools. Your validated env module runs on the server, so importing it into a client component either fails the build or, worse, drags a server-only secret toward the browser.
Keep two schemas: a server one with your secrets, and a client one containing onlyNEXT_PUBLIC_values. Never import the server env module from a file that carries'use client'. If a value has to reach the browser, it belongs in the client schema and the public prefix, on purpose.
Fail fast in CI
The schema also gives you a cheap CI check and a way to keep .env.example honest. Run a tiny script that imports the env module in a check mode, and keep an example file listing every key with placeholder values so a new developer knows exactly what to set.
.env.example + validate script
# .env.example — commit this, never the real .env
DATABASE_URL="postgresql://user:pass@localhost:5432/app"
STRIPE_KEY="sk_test_..."
PORT="3000"
NODE_ENV="development"
# package.json script
# "validate:env": "tsx ./scripts/check-env.ts"
# run it in CI before build so a missing key fails the pipeline, not prod
None of this is much code, and it is the kind you write once and forget. What you get back is a category of 2am incident that simply stops happening: configuration mistakes turn into a loud, immediate, well-labeled failure at deploy time instead of a quiet time bomb in a request handler.
