The Problem: Runtime Surprises
const apiKey = process.env.STRIPE_SECRET_KEY;
apiKey.startsWith('sk_'); // TypeError: Cannot read properties of undefined
You deployed to production. A required env var wasn't set. Your app crashes at the worst possible moment.
The Solution: Validate at Startup
Parse and validate environment variables when the process starts. Fail fast with clear errors instead of mysterious crashes later.
import { z } from 'zod';
const envSchema = z.object({
// Required strings
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
NEXTAUTH_SECRET: z.string().min(32),
// Optional with defaults
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().default(3000),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
// Optional fields
SENTRY_DSN: z.string().url().optional(),
REDIS_URL: z.string().url().optional(),
// Booleans from strings
ENABLE_FEATURE_FLAGS: z.string().transform(v => v === 'true').default('false'),
});
type Env = z.infer<typeof envSchema>;
function validateEnv(): Env {
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('Invalid environment variables:');
result.error.issues.forEach(issue => {
console.error(` ${issue.path.join('.')}: ${issue.message}`);
});
process.exit(1);
}
return result.data;
}
export const env = validateEnv();
Now instead of process.env.PORT, you use env.PORT—fully typed, validated, guaranteed non-null.
Full Error Output
When vars are missing or malformed:
Invalid environment variables:
DATABASE_URL: Invalid url
STRIPE_SECRET_KEY: Required
NEXTAUTH_SECRET: String must contain at least 32 character(s)
You know exactly what's wrong before your app ever starts handling requests.
Next.js Integration
Next.js has a split environment: server vs client. Handle both:
// src/env.mjs
import { z } from 'zod';
// Server-only env vars
const serverSchema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string(),
NEXTAUTH_SECRET: z.string(),
NODE_ENV: z.enum(['development', 'test', 'production']),
});
// Client-safe env vars (must be prefixed NEXT_PUBLIC_)
const clientSchema = z.object({
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
});
// Only validate server schema on server
const serverEnv = typeof window === 'undefined'
? serverSchema.parse(process.env)
: serverSchema.parse({});
const clientEnv = clientSchema.parse({
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
});
export const env = { ...serverEnv, ...clientEnv };
Advanced: Transformations
const envSchema = z.object({
// Comma-separated list → array
ALLOWED_ORIGINS: z.string()
.transform(s => s.split(',').map(s => s.trim()))
.default('http://localhost:3000'),
// JSON string → object
FEATURE_FLAGS: z.string()
.transform(s => JSON.parse(s) as Record<string, boolean>)
.optional(),
// URL → parsed URL object
DATABASE_URL: z.string().url()
.transform(s => new URL(s)),
// Number ranges
MAX_CONNECTIONS: z.coerce.number().min(1).max(100).default(10),
// Secret length validation
JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 characters'),
});
t3-env: The Production-Ready Version
For Next.js + TypeScript projects, @t3-oss/env-nextjs handles the boilerplate:
npm install @t3-oss/env-nextjs zod
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string(),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
},
// Map to process.env
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
},
});
Built-in: tree-shaking (client vars don't leak to server bundle), runtime validation, TypeScript autocomplete.
The .env.example Pattern
Maintain a .env.example with all required variables (no real values):
# .env.example — commit this
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
STRIPE_SECRET_KEY=sk_test_...
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
PORT=3000
Combined with startup validation, new developers know exactly what to set and get immediate feedback if anything is missing.
Stop shipping apps that silently fail because someone forgot to set SENDGRID_API_KEY in production.
Building a SaaS with proper env management from day one? The Whoff Agents AI SaaS Starter Kit includes t3-env setup and a complete .env.example.
Top comments (0)