Best Environment Variable Management for Node.js in 2026
·PkgPulse Team
TL;DR
t3-env or envalid for type-safe validation; dotenv for the simple case. dotenv (~40M weekly downloads) is the near-universal standard for loading .env files — but it gives you raw strings with no validation. t3-env (~400K downloads) wraps Zod to validate at startup and provide fully typed env vars. For any TypeScript project, validating environment variables at startup is worth the 10-minute setup — it catches misconfigured deploys before they cause runtime errors.
Key Takeaways
- dotenv: ~40M weekly downloads — load
.envfiles, raw strings, no validation - dotenv-expand: ~20M downloads — variable interpolation in
.envfiles - t3-env: ~400K downloads — Zod validation, TypeScript types, server/client split
- envalid: ~300K downloads — validation with clear error messages, older but solid
- @t3-oss/env-nextjs: ~300K downloads — Next.js-specific version of t3-env
dotenv (The Universal Standard)
// dotenv — load .env file, no validation
import dotenv from 'dotenv';
dotenv.config(); // Loads .env into process.env
// OR at startup (no import needed in Node 20+)
// node --env-file=.env server.js
// Raw access — no types, no validation
const port = process.env.PORT; // string | undefined
const dbUrl = process.env.DATABASE_URL; // string | undefined
// Fragile: what if PORT is "abc"? parseInt("abc") = NaN
const portNum = parseInt(process.env.PORT || '3000');
// .env file
// PORT=3000
// DATABASE_URL=postgresql://...
// NODE_ENV=production
// dotenv-expand — variable interpolation
import dotenv from 'dotenv';
import { expand } from 'dotenv-expand';
expand(dotenv.config());
// .env file with interpolation
// BASE_URL=https://api.example.com
// USERS_URL=${BASE_URL}/users <- uses BASE_URL
// ADMIN_URL=${BASE_URL}/admin <- uses BASE_URL
# .env.example — committed to repo as documentation
PORT=3000
DATABASE_URL=postgresql://localhost:5432/mydb
REDIS_URL=redis://localhost:6379
JWT_SECRET=your-secret-here
STRIPE_SECRET_KEY=sk_test_...
NODE_ENV=development
# .env — NOT committed (in .gitignore)
# .env.local — NOT committed (Next.js convention)
# .env.production — NOT committed
# .env.test — may be committed if no secrets
t3-env (Type-Safe Validation)
// t3-env — Zod schema validation at startup
// env.ts (or env/index.ts)
import { createEnv } from '@t3-oss/env-core';
import { z } from 'zod';
export const env = createEnv({
server: {
// Database
DATABASE_URL: z.string().url(),
REDIS_URL: z.string().url().optional(),
// Auth
JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 chars'),
JWT_EXPIRES_IN: z.string().default('7d'),
// Stripe
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
STRIPE_WEBHOOK_SECRET: z.string().startsWith('whsec_'),
// Email
RESEND_API_KEY: z.string().startsWith('re_'),
EMAIL_FROM: z.string().email(),
// App
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
},
client: {
// These are safe to expose to the browser (Next.js NEXT_PUBLIC_ prefix)
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
},
runtimeEnv: process.env, // Where to read from
emptyStringAsUndefined: true,
});
// Fully typed — TypeScript knows the shape
const port = env.PORT; // number (not string!)
const dbUrl = env.DATABASE_URL; // string
const jwtSecret = env.JWT_SECRET; // string
const redisUrl = env.REDIS_URL; // string | undefined
// t3-env — what happens when validation fails
// If DATABASE_URL is missing or invalid, you get:
// ❌ Invalid environment variables:
// DATABASE_URL: Required
// STRIPE_SECRET_KEY: Invalid input: must start with "sk_"
// JWT_SECRET: String must contain at least 32 character(s)
// ⚠️ These errors occur at STARTUP, not at runtime
// Instead of a cryptic runtime error like:
// TypeError: Cannot read property 'connect' of undefined
// t3-env — Next.js version
// env.ts
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().startsWith('sk_'),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
},
// Next.js exposes client vars via NEXT_PUBLIC_ prefix automatically
experimental__runtimeEnv: {
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
},
});
Zod Directly (DIY Validation)
// Roll your own with Zod — full control
// env.ts
import { z } from 'zod';
const envSchema = z.object({
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
JWT_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
});
// Parse and throw on validation errors
const result = envSchema.safeParse(process.env);
if (!result.success) {
console.error('❌ Invalid environment variables:', result.error.flatten().fieldErrors);
process.exit(1);
}
export const env = result.data;
// env.PORT is number, env.NODE_ENV is 'development' | 'test' | 'production'
envalid (Older but Ergonomic)
// envalid — clear error messages, no Zod required
import { cleanEnv, str, num, email, url, bool } from 'envalid';
export const env = cleanEnv(process.env, {
DATABASE_URL: url({ docs: 'https://docs.example.com/env' }),
PORT: num({ default: 3000 }),
NODE_ENV: str({ choices: ['development', 'test', 'production'] }),
JWT_SECRET: str(),
ENABLE_FEATURE_X: bool({ default: false }),
EMAIL_FROM: email(),
});
// Error output:
// Invalid value "abc" for env var "PORT": Expected a number
// See docs: https://docs.example.com/env
.env File Best Practices
# .env.example — committed, documents what vars are needed
DATABASE_URL=postgresql://localhost:5432/mydb
PORT=3000
JWT_SECRET= # required, must be 32+ chars
STRIPE_SECRET_KEY=sk_test_... # get from Stripe dashboard
# .gitignore
.env
.env.local
.env.*.local
!.env.example # this one IS committed
// env validation in CI — catch issues before deploy
// scripts/check-env.ts
import { env } from './env'; // Throws if invalid
// Run in CI: ts-node scripts/check-env.ts
// If this passes, all required env vars are present and valid
Comparison Table
| Approach | Downloads | Validation | TypeScript | Error Messages | Complexity |
|---|---|---|---|---|---|
| dotenv | 40M | ❌ | ❌ (raw strings) | Silent (undefined) | Minimal |
| Zod DIY | Via zod | ✅ Excellent | ✅ Inferred | Good | Low |
| t3-env | 400K | ✅ Excellent | ✅ Inferred | Excellent | Low |
| envalid | 300K | ✅ Good | ✅ | Excellent | Low |
When to Choose
| Scenario | Pick |
|---|---|
| Quick script or prototype | dotenv |
| Any TypeScript project | t3-env or Zod DIY |
| Next.js project | @t3-oss/env-nextjs |
| Client + server var separation | t3-env |
| Team that doesn't use Zod | envalid |
| Need custom validation logic | Zod DIY |
| CI env check script | t3-env or Zod DIY (exits with code 1) |
Compare env management package health on PkgPulse.
See the live comparison
View dotenv vs. cross env on PkgPulse →