Skip to main content

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 .env files, raw strings, no validation
  • dotenv-expand: ~20M downloads — variable interpolation in .env files
  • 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

ApproachDownloadsValidationTypeScriptError MessagesComplexity
dotenv40M❌ (raw strings)Silent (undefined)Minimal
Zod DIYVia zod✅ Excellent✅ InferredGoodLow
t3-env400K✅ Excellent✅ InferredExcellentLow
envalid300K✅ GoodExcellentLow

When to Choose

ScenarioPick
Quick script or prototypedotenv
Any TypeScript projectt3-env or Zod DIY
Next.js project@t3-oss/env-nextjs
Client + server var separationt3-env
Team that doesn't use Zodenvalid
Need custom validation logicZod DIY
CI env check scriptt3-env or Zod DIY (exits with code 1)

Compare env management package health on PkgPulse.

Comments

Stay Updated

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