Every Next.js project I've inherited has the same bug: a missing or misspelled environment variable that works locally (because .env.local has it) but breaks in production (because the deploy forgot it). The error is always cryptic — undefined is not a valid URL or fetch failed with no context.
t3-env solves this well. But it's a dependency with its own config syntax, and for most projects it's overkill. Here's the 40-line alternative I use in every project.
The setup
One file. No dependencies.
// lib/env.ts
import { z } from 'zod'
const server = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
SENTRY_DSN: z.string().url().optional(),
TELEGRAM_BOT_TOKEN: z.string().min(1),
TELEGRAM_CHAT_ID: z.string().min(1),
})
const client = z.object({
NEXT_PUBLIC_SITE_URL: z.string().url(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
NEXT_PUBLIC_SENTRY_DSN: z.string().url().optional(),
})
const serverEnv = server.safeParse(process.env)
const clientEnv = client.safeParse({
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
})
if (!serverEnv.success && typeof window === 'undefined') {
console.error(
'Invalid server environment variables:',
serverEnv.error.flatten().fieldErrors
)
throw new Error('Invalid server environment variables')
}
if (!clientEnv.success) {
console.error(
'Invalid client environment variables:',
clientEnv.error.flatten().fieldErrors
)
throw new Error('Invalid client environment variables')
}
export const env = {
...serverEnv.data!,
...clientEnv.data!,
}
That's it. 40 lines. Here's what you get:
What this gives you
1. Build-time validation
If DATABASE_URL is missing or isn't a valid URL, the build fails with a clear error:
Invalid server environment variables: { DATABASE_URL: ['Required'] }
Not "fetch failed at line 247." Not undefined is not a function. A clear message naming the exact variable.
2. Full IntelliSense
Every env.DATABASE_URL reference gets autocomplete and type checking. Typo env.DATABSE_URL? TypeScript catches it.
// ✅ TypeScript knows this is a string
const url = env.DATABASE_URL
// ❌ TypeScript error: Property 'DATABSE_URL' does not exist
const url = env.DATABSE_URL
3. Format validation
The Zod schema doesn't just check "exists" — it checks format:
z.string().url()— must be a valid URLz.string().startsWith('sk_')— must be a Stripe secret key (not a publishable key)z.string().min(1)— must not be an empty string
I've caught at least 3 bugs with the startsWith validation alone. The most common: using a test Stripe key in production, or a publishable key where a secret key is needed.
4. Client/server separation
The client object only lists NEXT_PUBLIC_* variables. This prevents accidentally exposing server secrets to the browser. If you try to add DATABASE_URL to the client schema, the value will be undefined (Next.js only exposes NEXT_PUBLIC_* to the client).
The typeof window === 'undefined' guard means server-only validation doesn't run in the browser — where server env vars are correctly absent.
The pattern for different environments
For projects with staging/production differences, add environment-specific validation:
const server = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']),
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().refine(
(key) => {
if (process.env.NODE_ENV === 'production') {
return key.startsWith('sk_live_')
}
return key.startsWith('sk_test_')
},
{ message: 'Stripe key must match the environment' }
),
})
This catches the exact bug I described in Why my Stripe test mode passed and production failed — a staging secret key deployed to production.
Why not t3-env
t3-env is good. I've used it. But it adds:
- A dependency (with its own update cycle)
- A custom config format (
createEnv({ ... })) - Framework coupling (Next.js-specific adapter)
The Zod-only approach has zero new dependencies (you already have Zod if you validate anything), uses standard Zod syntax that your team already knows, and works in any TypeScript project — not just Next.js.
For a large monorepo with shared env schemas across packages, t3-env's extends feature is worth the dependency. For a single Next.js app, the 40-line file is enough.
Where to import from
// ✅ Always import from lib/env
import { env } from '@/lib/env'
const dbUrl = env.DATABASE_URL
// ❌ Never use process.env directly
const dbUrl = process.env.DATABASE_URL
Add an ESLint rule to enforce this:
// .eslintrc.js
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'MemberExpression[object.name="process"][property.name="env"]',
message: 'Use env from @/lib/env instead of process.env',
},
],
}
This catches stray process.env accesses and points developers to the typed alternative.
The 5-minute migration
If you have an existing project using raw process.env:
- Create
lib/env.tswith the schema above - Replace every
process.env.Xwithenv.X(global find-replace) - Run
npx tsc --noEmit— TypeScript will flag any variables you missed in the schema - Deploy — if any variable is missing, the build tells you which one
Step 3 is the magic moment. TypeScript becomes your audit tool — it finds every env reference in the codebase and tells you if it's in the schema.
Want a clean, validated, fully typed config for your Next.js project? Let's talk — this is part of the baseline setup I ship with every new build.