Type-Safe Environment Variables in Next.js (And Why You Need Them)
A small chunk of TypeScript that prevents an entire class of production bugs. Validating environment variables at build time with Zod and t3-env.
The bug that kills a Friday afternoon
You deploy a feature to production. Five minutes later, customers report errors. The app crashes on first request with a TypeError saying cannot read property of undefined. You dig in. The cause: a new environment variable you added locally but forgot to set on Vercel.
This bug is preventable. The fix takes 20 minutes once, then never bites you again. This post is the fix.
The pattern
The idea: validate all environment variables at build time, with TypeScript types that flow through your entire app. If a required variable is missing or has the wrong shape, the build fails loudly instead of silently in production.
Tools:
Total install: 2 packages. Total code: about 40 lines.
Setup
In your Next.js project root, install the two packages: @t3-oss/env-nextjs and zod.
Create an env.ts file at the root. Inside, you call createEnv with three sections:
For each variable you list the Zod validator. For example, DATABASE_URL gets z.string().url(), STRIPE_SECRET_KEY gets z.string().min(1), NODE_ENV gets z.enum with development, test, production options.
How you use it
Anywhere in your app, import env from your env.ts file and access variables with full type safety. On the server, you can use env.STRIPE_SECRET_KEY when creating your Stripe client. On the client, you can use env.NEXT_PUBLIC_APP_URL freely.
If you try to access a server-only variable in client code, TypeScript errors at compile time. If you try to use a variable name that does not exist in the schema, TypeScript errors. If you misspell a variable, TypeScript errors.
What happens when a variable is missing
When you run npm run build, if STRIPE_SECRET_KEY is not set, the build fails immediately with a clear message saying invalid environment variables, pointing at the specific variable, with the exact validation error.
Clear error. Specific variable. Exact reason. No mystery bug in production at 3pm on Friday.
Validating beyond "not empty"
Zod gives you the full validation language. Use it.
Now PORT is a number (not a string). ALLOWED_ORIGINS is an array. LOG_LEVEL is one of four literals. All checked at build time.
The gotchas
Vercel and other platforms
The validation only catches missing variables if you run a build that has access to all environments. On Vercel, this just works — Vercel injects your env vars during the build. On other platforms, make sure your CI sets all env vars before building.
Skip validation locally (sometimes)
For quick local experiments where you do not want to set every variable, t3-env lets you skip validation with SKIP_ENV_VALIDATION=true. Use sparingly.
Optional variables
Mark optional variables explicitly with .optional() at the end of the Zod chain. If they are missing, the value is undefined and the build succeeds. If they are set but malformed, the build fails.
Default values
For non-secret variables with sensible defaults, chain .default("value") on the Zod validator. Saves you from having to set every variable in every environment.
Why this matters
The before-and-after:
Before: A missing env var is a runtime undefined error that surfaces minutes or hours after deploy, often in a flow most developers do not test locally.
After: A missing env var fails the build with a specific error pointing at the variable, the schema, and what was wrong.
The first scenario costs a Friday afternoon. The second costs a fix in 30 seconds.
For any production app, this is one of the highest-leverage changes you can make. 20 minutes of setup, eliminates an entire class of bugs forever.
My env setup in 2026
TL;DR
If you are setting up a new Next.js project or want a senior to audit your existing setup, contact me.
You might also like
Multi-Tenant SaaS with Next.js, Prisma, and Stripe in 2026
Architecture decisions for a multi-tenant SaaS in Next.js 14 — schema-per-tenant vs row-level isolation, Prisma patterns, Stripe Connect vs Stripe Billing, and the pitfalls.
Stripe Billing for SaaS in 2026: Subscription Patterns That Actually Ship
Real patterns I use to wire Stripe Billing into multi-tenant SaaS — checkout, webhooks, customer portal, plan changes, dunning, and the gotchas that break apps in production.
Drizzle ORM in Production: Patterns I Use After 6 Client Projects
Real Drizzle patterns from shipping it on 6 production apps in the last year — schema design, type safety, migrations, query patterns, and where Drizzle still falls short of Prisma.