DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Type-Safe Environment Variables in Node.js with Zod

The Problem: Runtime Surprises

const apiKey = process.env.STRIPE_SECRET_KEY;
apiKey.startsWith('sk_'); // TypeError: Cannot read properties of undefined
Enter fullscreen mode Exit fullscreen mode

You deployed to production. A required env var wasn't set. Your app crashes at the worst possible moment.

The Solution: Validate at Startup

Parse and validate environment variables when the process starts. Fail fast with clear errors instead of mysterious crashes later.

import { z } from 'zod';

const envSchema = z.object({
  // Required strings
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  NEXTAUTH_SECRET: z.string().min(32),

  // Optional with defaults
  NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
  PORT: z.coerce.number().default(3000),
  LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),

  // Optional fields
  SENTRY_DSN: z.string().url().optional(),
  REDIS_URL: z.string().url().optional(),

  // Booleans from strings
  ENABLE_FEATURE_FLAGS: z.string().transform(v => v === 'true').default('false'),
});

type Env = z.infer<typeof envSchema>;

function validateEnv(): Env {
  const result = envSchema.safeParse(process.env);

  if (!result.success) {
    console.error('Invalid environment variables:');
    result.error.issues.forEach(issue => {
      console.error(`  ${issue.path.join('.')}: ${issue.message}`);
    });
    process.exit(1);
  }

  return result.data;
}

export const env = validateEnv();
Enter fullscreen mode Exit fullscreen mode

Now instead of process.env.PORT, you use env.PORT—fully typed, validated, guaranteed non-null.

Full Error Output

When vars are missing or malformed:

Invalid environment variables:
  DATABASE_URL: Invalid url
  STRIPE_SECRET_KEY: Required
  NEXTAUTH_SECRET: String must contain at least 32 character(s)
Enter fullscreen mode Exit fullscreen mode

You know exactly what's wrong before your app ever starts handling requests.

Next.js Integration

Next.js has a split environment: server vs client. Handle both:

// src/env.mjs
import { z } from 'zod';

// Server-only env vars
const serverSchema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string(),
  NEXTAUTH_SECRET: z.string(),
  NODE_ENV: z.enum(['development', 'test', 'production']),
});

// Client-safe env vars (must be prefixed NEXT_PUBLIC_)
const clientSchema = z.object({
  NEXT_PUBLIC_APP_URL: z.string().url(),
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
});

// Only validate server schema on server
const serverEnv = typeof window === 'undefined'
  ? serverSchema.parse(process.env)
  : serverSchema.parse({});

const clientEnv = clientSchema.parse({
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
});

export const env = { ...serverEnv, ...clientEnv };
Enter fullscreen mode Exit fullscreen mode

Advanced: Transformations

const envSchema = z.object({
  // Comma-separated list → array
  ALLOWED_ORIGINS: z.string()
    .transform(s => s.split(',').map(s => s.trim()))
    .default('http://localhost:3000'),

  // JSON string → object  
  FEATURE_FLAGS: z.string()
    .transform(s => JSON.parse(s) as Record<string, boolean>)
    .optional(),

  // URL → parsed URL object
  DATABASE_URL: z.string().url()
    .transform(s => new URL(s)),

  // Number ranges
  MAX_CONNECTIONS: z.coerce.number().min(1).max(100).default(10),

  // Secret length validation
  JWT_SECRET: z.string().min(32, 'JWT secret must be at least 32 characters'),
});
Enter fullscreen mode Exit fullscreen mode

t3-env: The Production-Ready Version

For Next.js + TypeScript projects, @t3-oss/env-nextjs handles the boilerplate:

npm install @t3-oss/env-nextjs zod
Enter fullscreen mode Exit fullscreen mode
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(),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
  },
  // Map to process.env
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  },
});
Enter fullscreen mode Exit fullscreen mode

Built-in: tree-shaking (client vars don't leak to server bundle), runtime validation, TypeScript autocomplete.

The .env.example Pattern

Maintain a .env.example with all required variables (no real values):

# .env.example — commit this
DATABASE_URL=postgresql://user:password@localhost:5432/mydb
STRIPE_SECRET_KEY=sk_test_...
NEXTAUTH_SECRET=generate-with-openssl-rand-base64-32
PORT=3000
Enter fullscreen mode Exit fullscreen mode

Combined with startup validation, new developers know exactly what to set and get immediate feedback if anything is missing.

Stop shipping apps that silently fail because someone forgot to set SENDGRID_API_KEY in production.


Building a SaaS with proper env management from day one? The Whoff Agents AI SaaS Starter Kit includes t3-env setup and a complete .env.example.

Top comments (0)