Every Next.js SaaS has a .env file full of secrets assumed to be present. In practice they are not always there. A missing DATABASE_URL or JWT_SECRET turns into a runtime error deep inside a request handler, after the server has already accepted traffic.
The fix: validate every required variable when the process starts. If something is missing, crash immediately with a clear message.
Create lib/env.ts:
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().min(1),
JWT_SECRET: z.string().min(32),
NEXT_PUBLIC_BASE_URL: z.string().url(),
ANTHROPIC_API_KEY: z.string().min(1).optional(),
RESEND_API_KEY: z.string().min(1).optional(),
CLOUDINARY_CLOUD_NAME: z.string().min(1).optional(),
CLOUDINARY_API_KEY: z.string().min(1).optional(),
CLOUDINARY_API_SECRET: z.string().min(1).optional(),
STRIPE_SECRET_KEY: z.string().min(1).optional(),
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
});
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {
console.error("[env] Missing or invalid environment variables:");
console.error(parsed.error.flatten().fieldErrors);
process.exit(1);
}
export const env = parsed.data;
Import env instead of process.env anywhere you need a typed secret:
import { env } from "@/lib/env";
const client = new Anthropic({ apiKey: env.ANTHROPIC_API_KEY });
TypeScript now knows the shape. If you add a required variable to the schema and forget to set it, you get a startup crash -- not a TypeError: Cannot read properties of undefined mid-request.
safeParse does not throw. It returns { success: false, error }, which lets you log every missing variable at once before exiting. With parse(), you get one error per call and the stack trace points at Zod internals rather than your code.
process.env is server-side. Next.js inlines NEXT_PUBLIC_* values at build time, but the module-level safeParse runs on the server. You can include public variables in the schema -- they are present when the server validates on startup.
Do not import lib/env.ts in a Client Component. It is a server-only module. For client-side access, read process.env.NEXT_PUBLIC_FOO directly or pass the value down as a prop.
Import the module at the top of app/layout.tsx:
import "@/lib/env"; // validates on cold start
The root layout is a server component that runs on every cold start. Putting the import here guarantees validation before any route handles a request. If you put it inside a specific route handler, the check runs only when that route is first hit -- too late.
When you add a new secret to .env, add it to envSchema at the same time. In a Next.js SaaS boilerplate used with Claude Code, this is easy to enforce: add the variable name to both lib/env.ts and the ## Environment Variables section of CLAUDE.md. Claude will reference both files when scaffolding new features and keep them in sync across the codebase.
Vercel aggregates logs per function invocation. A process.exit(1) on cold start shows up as a function invocation failure. The [env] prefix makes it easy to grep:
[env] Missing or invalid environment variables:
{ STRIPE_SECRET_KEY: [ 'Required' ] }
You see exactly which variable is missing, not a stack trace buried in your database driver.
Zod gives you more than presence checks. Use it for format validation too:
const envSchema = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32, "JWT_SECRET must be at least 32 characters"),
PORT: z.coerce.number().int().positive().default(3000),
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
});
z.coerce.number() handles the fact that process.env values are always strings. z.enum catches typos in NODE_ENV before they cause subtle behavior differences.
Validate all required env vars at startup, export a typed env object, and import it everywhere instead of process.env. The pattern takes five minutes to set up and eliminates an entire class of production incidents -- secrets present locally but missing in the deployed environment. In a full-stack TypeScript SaaS boilerplate, this belongs in lib/env.ts from day one.