dotenv vs @t3-oss/env-nextjs vs envalid: Env Variable Validation in Node.js (2026)
TL;DR
dotenv loads .env files — but provides zero type safety or validation. @t3-oss/env-nextjs (from the T3 Stack) gives you Zod-validated, TypeScript-typed environment variables with Next.js App Router support and separate client/server variable groups. envalid is the simpler validation option for non-Next.js Node.js apps — built-in validators, readable error messages, and no Zod dependency. For new Next.js projects, @t3-oss/env-nextjs is the clear choice.
Key Takeaways
- dotenv: ~55M weekly downloads — loads
.env, no validation, used everywhere - @t3-oss/env-nextjs: ~1.2M weekly downloads — Zod-powered, Next.js-native, client/server split
- envalid: ~650K weekly downloads — built-in validators, fail-fast validation, no external deps
- The core problem:
process.env.DATABASE_URLis alwaysstring | undefined— no types, no validation - Use dotenv for loading + one of the validators for typing
- In 2026, most new Next.js projects use
@t3-oss/env-nextjswith Zod
The Problem with Raw process.env
// Without validation:
const dbUrl = process.env.DATABASE_URL // string | undefined — always
// Bug: app starts, crashes 5 minutes later when first DB query runs
await db.connect(dbUrl) // TypeScript doesn't catch this at build time
// Worse: typo in env var name — no error, just undefined at runtime
const secret = process.env.JWT_SECERT // undefined — silent bug
dotenv
dotenv is the universal .env file loader — it handles the parsing and process.env population, not validation.
import "dotenv/config"
// Or:
import dotenv from "dotenv"
dotenv.config()
// Now process.env is populated from .env file:
console.log(process.env.DATABASE_URL) // string | undefined — still untyped
# .env file:
DATABASE_URL=postgresql://localhost/pkgpulse
REDIS_URL=redis://localhost:6379
JWT_SECRET=my-super-secret-key
PORT=3000
dotenv with path override:
dotenv.config({ path: ".env.local" }) // Development overrides
dotenv.config({ path: ".env.production" }) // Production values
dotenv.config({ override: true }) // Override existing env vars
dotenv-expand (variable substitution):
import { expand } from "dotenv-expand"
import dotenv from "dotenv"
const env = dotenv.config()
expand(env)
# .env with variable expansion:
DB_HOST=localhost
DB_PORT=5432
DB_NAME=pkgpulse
DATABASE_URL=postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}
dotenv with manual TypeScript types (the workaround pattern):
// env.ts — typed access without a validation library:
const requiredEnvVars = [
"DATABASE_URL",
"JWT_SECRET",
"REDIS_URL",
] as const
type EnvVars = {
[K in (typeof requiredEnvVars)[number]]: string
}
export function getEnv(): EnvVars {
const missing = requiredEnvVars.filter((key) => !process.env[key])
if (missing.length > 0) {
throw new Error(`Missing env vars: ${missing.join(", ")}`)
}
return Object.fromEntries(
requiredEnvVars.map((key) => [key, process.env[key]!])
) as EnvVars
}
export const env = getEnv() // Throws at startup if vars missing
This works but is verbose and doesn't handle type coercion (port as number, booleans, etc.).
@t3-oss/env-nextjs
@t3-oss/env-nextjs is the standard env validation library for T3 Stack (Next.js + TypeScript + Zod) projects.
// src/env.ts — define once, use everywhere:
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"
export const env = createEnv({
/**
* Server-side env vars — NOT exposed to browser
*/
server: {
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
REDIS_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
RESEND_API_KEY: z.string().startsWith("re_"),
OPENAI_API_KEY: z.string().startsWith("sk-"),
},
/**
* Client-side env vars — must be prefixed with NEXT_PUBLIC_
* These are bundled into the client JS
*/
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
},
/**
* Maps `process.env` to the schema — needed for Next.js
*/
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
JWT_SECRET: process.env.JWT_SECRET,
REDIS_URL: process.env.REDIS_URL,
PORT: process.env.PORT,
NODE_ENV: process.env.NODE_ENV,
RESEND_API_KEY: process.env.RESEND_API_KEY,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
},
// Skip validation in CI/test where not all vars are set:
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
})
Using the validated env:
// server-only.ts (Next.js Server Component or API route):
import { env } from "@/env"
// Fully typed — no string | undefined:
const db = new PrismaClient({ datasourceUrl: env.DATABASE_URL })
const redis = new Redis(env.REDIS_URL)
// TypeScript knows PORT is a number (z.coerce.number()):
const port = env.PORT // number, not string!
// Attempting to access server var in client code → build error:
// env.JWT_SECRET ← TypeScript error in client components
t3-env build-time validation:
// next.config.mjs — validate at build time:
import "./src/env.ts" // Throws if env invalid during next build
/** @type {import('next').NextConfig} */
const config = {
// ...
}
export default config
If DATABASE_URL is missing, next build fails immediately with a clear error — not a runtime crash in production.
t3-env for non-Next.js:
// Using the framework-agnostic version:
import { createEnv } from "@t3-oss/env-core"
import { z } from "zod"
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
PORT: z.coerce.number().default(3000),
},
runtimeEnv: process.env,
})
envalid
envalid provides built-in validators without requiring Zod — smaller API surface.
import { cleanEnv, str, url, port, num, bool, email } from "envalid"
export const env = cleanEnv(process.env, {
// Built-in validators:
DATABASE_URL: url(), // Must be valid URL
JWT_SECRET: str({ minLength: 32 }), // Non-empty string
PORT: port({ default: 3000 }), // Port 1-65535, coerced to number
REDIS_URL: url(),
DEBUG: bool({ default: false }), // "true"/"false"/"1"/"0" → boolean
SMTP_PORT: num({ default: 587 }), // Coerced to number
ADMIN_EMAIL: email(), // Must be valid email
// With documentation:
API_KEY: str({
docs: "https://app.example.com/api-keys",
example: "pk_live_abc123",
}),
})
envalid output:
If a variable fails validation, envalid prints a clear error and exits:
Invalid or missing env vars:
JWT_SECRET: Value "" is too short (min length: 32)
DATABASE_URL: Value "not-a-url" is not a valid URL
envalid usage:
// Fully typed — validators coerce types:
const port = env.PORT // number (not string)
const debug = env.DEBUG // boolean (not string)
const dbUrl = env.DATABASE_URL // string (validated URL)
// Access the raw values object (all validated):
console.log(env.DATABASE_URL)
// Dev-only values (fail in production if not set):
import { cleanEnv, str } from "envalid"
cleanEnv(process.env, {
DATABASE_URL: str(),
// devOnly: throws in production, but not development
DEBUG_BYPASS_AUTH: str({ devOnly: true, default: "" }),
})
Custom validators:
import { makeValidator, cleanEnv } from "envalid"
// Custom validator function:
const json = makeValidator<Record<string, unknown>>((input) => {
try {
return JSON.parse(input)
} catch {
throw new Error("Invalid JSON")
}
})
const featureFlags = makeValidator<string[]>((input) => {
const flags = input.split(",").map((s) => s.trim())
if (flags.some((f) => !f)) throw new Error("Invalid flag format")
return flags
})
export const env = cleanEnv(process.env, {
FEATURE_FLAGS: featureFlags({ default: [] }),
SERVICE_CONFIG: json(),
})
Feature Comparison
| Feature | dotenv | @t3-oss/env-nextjs | envalid |
|---|---|---|---|
.env file loading | ✅ | ✅ (uses dotenv) | ✅ (uses dotenv) |
| TypeScript types | ❌ | ✅ Inferred from Zod | ✅ Built-in types |
| Validation | ❌ | ✅ Zod schemas | ✅ Built-in validators |
| Type coercion (port→number) | ❌ | ✅ z.coerce | ✅ |
| Client/server split | ❌ | ✅ Next.js-native | ❌ |
| Build-time validation | ❌ | ✅ | ✅ |
| Custom validators | ❌ | ✅ Any Zod | ✅ makeValidator |
| Error messages | ❌ | ✅ Zod errors | ✅ Readable |
| Requires Zod | ❌ | ✅ | ❌ |
| Ecosystem | ✅ Universal | Next.js focused | Node.js |
When to Use Each
Choose dotenv alone if:
- You're adding it to a legacy codebase incrementally
- The project is small and env var mistakes are low-risk
- You pair it with manual validation (the custom
getEnv()pattern)
Choose @t3-oss/env-nextjs if:
- Building a Next.js application (App Router or Pages Router)
- You want the client/server split that prevents secret leakage to the browser
- Your team already uses Zod for validation
- Build-time validation that fails
next buildis valuable to you
Choose envalid if:
- You want simple env validation without Zod as a dependency
- Building a non-Next.js Node.js app (Express, Fastify, workers)
- The built-in
str,url,port,bool,numvalidators cover your needs - Readable error messages and
devOnlysemantics appeal to your workflow
Methodology
Download data from npm registry (weekly average, February 2026). Feature comparison based on dotenv v16.x, @t3-oss/env-nextjs v0.10.x, and envalid v8.x documentation.
Compare configuration and environment packages on PkgPulse →