Skip to main content

dotenv vs @t3-oss/env-nextjs vs envalid: Env Variable Validation in Node.js (2026)

·PkgPulse Team

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_URL is always string | 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-nextjs with 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

Featuredotenv@t3-oss/env-nextjsenvalid
.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✅ UniversalNext.js focusedNode.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 build is 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, num validators cover your needs
  • Readable error messages and devOnly semantics 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 →

Comments

Stay Updated

Get the latest package insights, npm trends, and tooling tips delivered to your inbox.