Skip to main content

Guide

dotenv vs @t3-oss/env-nextjs vs envalid 2026

Compare dotenv, @t3-oss/env-nextjs, and envalid for environment variable management. Type safety, Zod integration, Next.js App Router support, and which env.

·PkgPulse Team·
0

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

Migration Guide

From dotenv to @t3-oss/env-nextjs

The migration adds type safety and validation on top of your existing .env files:

// Before (dotenv only — untyped)
import "dotenv/config"
const dbUrl = process.env.DATABASE_URL  // string | undefined — no validation
const port = parseInt(process.env.PORT ?? "3000", 10)  // manual coercion

// After (@t3-oss/env-nextjs — typed and validated)
// src/env.ts
import { createEnv } from "@t3-oss/env-nextjs"
import { z } from "zod"

export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    PORT: z.coerce.number().default(3000),
  },
  client: {
    NEXT_PUBLIC_API_URL: z.string().url(),
  },
  experimental__runtimeEnv: {
    NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
  },
})

// Usage — fully typed, no undefined
import { env } from "@/env"
const db = new PrismaClient({ datasourceUrl: env.DATABASE_URL })
const port: number = env.PORT  // TypeScript knows this is number

Your existing .env file doesn't change — only the import and consumption pattern changes.

Community Adoption in 2026

dotenv leads with approximately 40 million weekly downloads, reflecting its position as a foundational Node.js tool present in virtually every project that has environment variables. It is a direct dependency in tools like Jest, nodemon, and countless frameworks. Its simplicity — require("dotenv").config() — makes it universally understood across experience levels.

@t3-oss/env-nextjs (and the framework-agnostic @t3-oss/env-core) reaches approximately 400,000 weekly downloads, growing rapidly as the create-t3-app template (which bundles it by default) has become a popular Next.js starter for TypeScript projects. Teams adopting the T3 stack get @t3-oss/env-nextjs pre-configured and rarely replace it once familiar with the type safety it provides.

envalid at approximately 200,000 weekly downloads serves projects that want validation without Zod as a dependency. It is particularly popular in backend-focused Node.js projects (Fastify APIs, workers, CLI tools) where the Next.js client/server split concept doesn't apply and teams prefer envalid's lightweight, self-contained validator vocabulary.

Secret Management and Production Environment Strategy

Environment variable management in production involves concerns beyond local development: secret rotation, different values per deployment target, and preventing accidental exposure in logs or error messages.

Secret rotation requires that your application can pick up new environment variable values without a full redeployment. dotenv loads variables at process startup and has no mechanism for runtime refresh — if a database password rotates, the old process must be restarted. For applications deployed on platforms like Railway, Fly.io, or Render, secret rotation typically means a rolling restart, which is operationally straightforward. For long-running processes in Kubernetes, using a secrets CSI driver that mounts secrets as files and watches for changes is more sophisticated than environment variables.

Platform-specific secret storage integrates differently with each approach. Vercel, Netlify, and Railway all inject secrets as environment variables at build time and runtime. This works naturally with dotenv (which merges with process.env), t3-env (which validates process.env), and envalid (which reads from process.env). AWS Lambda, by contrast, supports environment variables injected at function configuration time, but larger secrets (certificates, API keys) are often stored in AWS Secrets Manager and retrieved at function startup — a pattern that requires explicit fetching code rather than environment variable loading.

Preventing secret exposure in logs is an operational concern. A common production incident pattern: an unhandled error logs process.env or req.body containing API keys. t3-env's strict validation fails at startup if required variables are missing, preventing the application from starting with bad configuration — which is better than failing at request time. envalid's makeValidator patterns can be used to mark variables as sensitive, preventing them from appearing in validation error messages.

The .env.local.env.production pattern in Next.js (and Vite) gives fine-grained control per environment without platform configuration. dotenv handles this via dotenv-flow or explicit require('dotenv').config({ path: '.env.production' }). t3-env and envalid both work with whatever process.env contains after dotenv loading, making them compatible with any dotenv variant. For teams using Docker, .env files are generally avoided in favor of explicit --env-file flags or orchestration-level secret injection, keeping secrets out of the container image entirely.

Edge Runtime and Serverless Considerations

Environment variable handling in edge runtimes (Cloudflare Workers, Vercel Edge Functions, Deno Deploy) differs fundamentally from Node.js. These runtimes do not support the Node.js process.env object in the same way, and dotenv's file-loading mechanism is not applicable — edge runtimes do not have filesystem access. Environment variables in Cloudflare Workers are accessed via bindings configured in wrangler.toml, not via process.env at runtime. Vercel Edge Functions receive environment variables as process.env but the values are injected at build time, making runtime dotenv loading inapplicable.

For projects that span both Node.js and edge runtimes, @t3-oss/env-nextjs's experimental__runtimeEnv configuration explicitly maps which environment variables are available in the edge runtime. This makes the client/server split semantics precise for edge use cases. envalid works correctly in edge runtimes as long as the environment variables are already in process.env (injected by the platform) — its validation logic has no dependency on filesystem access or Node.js-specific APIs.

The validation-at-startup pattern provided by t3-env and envalid is particularly valuable in serverless and edge contexts where cold starts are visible to users. A misconfigured environment variable that would cause a runtime error during a request now fails at initialization time, preventing the cold start from appearing healthy while subsequent requests fail. For Cloudflare Workers, this means the wrangler deploy command will catch missing bindings before the Worker goes live if you validate bindings in the Worker's startup code.

Environment variable validation also serves as living documentation. An envalid or t3-env schema listing every environment variable with its type, description, and whether it is required serves as the authoritative reference for onboarding new developers. When the schema is defined in a single file that is imported by every entry point of the application, adding a new environment variable becomes a two-step process: add it to the schema file, then add it to the .env.example template. This workflow is more reliable than maintaining a separate README section describing required environment variables, which inevitably drifts out of sync as the application evolves.

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 →

See also: dotenv vs env-cmd and Next.js vs Remix, acorn vs @babel/parser vs espree.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.