Documentation
Environment Variables
Env Validation

Env Validation

Validate environment variables to catch configuration errors early.

Why Validate?

  • Fail fast - Catch missing vars at startup, not in production
  • Type safety - TypeScript knows your env var types
  • Documentation - Schema documents required variables
  • Transformation - Parse strings to numbers, booleans

Zod Validation

npm install zod

Basic Setup

// lib/env.ts
import { z } from 'zod';
 
const envSchema = z.object({
  // Node environment
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
 
  // App
  NEXT_PUBLIC_APP_URL: z.string().url(),
  NEXT_PUBLIC_APP_NAME: z.string().default('MyApp'),
 
  // API
  NEXT_PUBLIC_API_URL: z.string().url(),
 
  // Database (server only)
  DATABASE_URL: z.string().min(1),
 
  // Auth
  NEXTAUTH_SECRET: z.string().min(32),
  NEXTAUTH_URL: z.string().url(),
 
  // Feature flags
  NEXT_PUBLIC_ENABLE_ANALYTICS: z
    .string()
    .transform((val) => val === 'true')
    .default('false'),
 
  // Optional
  SENTRY_DSN: z.string().url().optional(),
});
 
// Validate and export
export const env = envSchema.parse(process.env);
 
// Type export
export type Env = z.infer<typeof envSchema>;

Usage

import { env } from '@/lib/env';
 
// Fully typed!
console.log(env.NEXT_PUBLIC_APP_URL); // string
console.log(env.NEXT_PUBLIC_ENABLE_ANALYTICS); // boolean
console.log(env.SENTRY_DSN); // string | undefined

Separating Client and Server

// lib/env/client.ts
import { z } from 'zod';
 
const clientEnvSchema = z.object({
  NEXT_PUBLIC_APP_URL: z.string().url(),
  NEXT_PUBLIC_API_URL: z.string().url(),
  NEXT_PUBLIC_ENABLE_ANALYTICS: z
    .string()
    .transform((val) => val === 'true')
    .default('false'),
});
 
export const clientEnv = clientEnvSchema.parse({
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
  NEXT_PUBLIC_ENABLE_ANALYTICS: process.env.NEXT_PUBLIC_ENABLE_ANALYTICS,
});
// lib/env/server.ts
import { z } from 'zod';
import 'server-only';
 
const serverEnvSchema = z.object({
  DATABASE_URL: z.string().min(1),
  NEXTAUTH_SECRET: z.string().min(32),
  API_SECRET_KEY: z.string().min(1),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
});
 
export const serverEnv = serverEnvSchema.parse(process.env);
// lib/env/index.ts
import { clientEnv } from './client';
 
// Re-export client env (safe for browser)
export { clientEnv };
 
// Server env only available via direct import
// import { serverEnv } from '@/lib/env/server';

T3 Env (Recommended)

The T3 stack's env package provides an excellent solution:

npm install @t3-oss/env-nextjs zod
// env.mjs
import { createEnv } from '@t3-oss/env-nextjs';
import { z } from 'zod';
 
export const env = createEnv({
  // Server-side variables
  server: {
    DATABASE_URL: z.string().url(),
    NEXTAUTH_SECRET: z.string().min(32),
    STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  },
 
  // Client-side variables (must be prefixed with NEXT_PUBLIC_)
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
  },
 
  // Runtime values (for Next.js)
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
    STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
    NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
  },
 
  // Skip validation in certain conditions
  skipValidation: !!process.env.SKIP_ENV_VALIDATION,
 
  // Empty strings are treated as undefined
  emptyStringAsUndefined: true,
});
// Usage
import { env } from '@/env.mjs';
 
// Client components - only client vars available
const stripeKey = env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
 
// Server components/API routes - all vars available
const dbUrl = env.DATABASE_URL;

Custom Validators

const envSchema = z.object({
  // URL that must be HTTPS in production
  API_URL: z
    .string()
    .url()
    .refine(
      (url) => process.env.NODE_ENV !== 'production' || url.startsWith('https'),
      'API_URL must use HTTPS in production'
    ),
 
  // Port number
  PORT: z
    .string()
    .transform((val) => parseInt(val, 10))
    .refine((val) => val >= 1 && val <= 65535, 'Invalid port number')
    .default('3000'),
 
  // Comma-separated list to array
  ALLOWED_ORIGINS: z
    .string()
    .transform((val) => val.split(',').map((s) => s.trim()))
    .default('http://localhost:3000'),
 
  // JSON string to object
  FEATURE_FLAGS: z
    .string()
    .transform((val) => JSON.parse(val))
    .pipe(
      z.object({
        newDashboard: z.boolean(),
        betaFeatures: z.boolean(),
      })
    )
    .optional(),
});

Error Handling

// lib/env.ts
import { z } from 'zod';
 
const envSchema = z.object({
  DATABASE_URL: z.string().min(1, 'DATABASE_URL is required'),
  API_KEY: z.string().min(1, 'API_KEY is required'),
});
 
function validateEnv() {
  const result = envSchema.safeParse(process.env);
 
  if (!result.success) {
    console.error('❌ Invalid environment variables:');
    console.error(result.error.flatten().fieldErrors);
 
    // In development, show detailed errors
    if (process.env.NODE_ENV === 'development') {
      throw new Error('Invalid environment variables');
    }
 
    // In production, fail silently but log
    process.exit(1);
  }
 
  return result.data;
}
 
export const env = validateEnv();

Build-Time Validation

// next.config.mjs
import './lib/env.mjs'; // This runs validation at build time
 
/** @type {import('next').NextConfig} */
const nextConfig = {
  // ...
};
 
export default nextConfig;

TypeScript Declarations

// env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    NODE_ENV: 'development' | 'production' | 'test';
    NEXT_PUBLIC_APP_URL: string;
    NEXT_PUBLIC_API_URL: string;
    DATABASE_URL: string;
    // Add all your env vars here
  }
}

Testing

// __tests__/env.test.ts
import { z } from 'zod';
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  API_KEY: z.string().min(1),
});
 
describe('Environment Variables', () => {
  it('validates correct env', () => {
    const env = {
      DATABASE_URL: 'postgresql://localhost:5432/db',
      API_KEY: 'secret-key',
    };
 
    expect(() => envSchema.parse(env)).not.toThrow();
  });
 
  it('rejects missing DATABASE_URL', () => {
    const env = {
      API_KEY: 'secret-key',
    };
 
    expect(() => envSchema.parse(env)).toThrow();
  });
 
  it('rejects invalid DATABASE_URL', () => {
    const env = {
      DATABASE_URL: 'not-a-url',
      API_KEY: 'secret-key',
    };
 
    expect(() => envSchema.parse(env)).toThrow();
  });
});

Complete Example

// lib/env.ts
import { z } from 'zod';
 
// Schema
const envSchema = z.object({
  // App
  NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
  NEXT_PUBLIC_APP_URL: z.string().url(),
  NEXT_PUBLIC_APP_NAME: z.string().default('MyApp'),
 
  // API
  NEXT_PUBLIC_API_URL: z.string().url(),
  API_SECRET_KEY: z.string().min(1),
 
  // Database
  DATABASE_URL: z.string().min(1),
 
  // Auth
  NEXTAUTH_SECRET: z.string().min(32),
  NEXTAUTH_URL: z.string().url(),
 
  // Services
  STRIPE_SECRET_KEY: z.string().startsWith('sk_').optional(),
  NEXT_PUBLIC_STRIPE_KEY: z.string().startsWith('pk_').optional(),
 
  // Features
  NEXT_PUBLIC_ANALYTICS_ENABLED: z
    .string()
    .transform((v) => v === 'true')
    .default('false'),
});
 
// Validate
const parsed = envSchema.safeParse(process.env);
 
if (!parsed.success) {
  console.error('❌ Invalid environment variables:', parsed.error.flatten().fieldErrors);
  throw new Error('Invalid environment variables');
}
 
export const env = parsed.data;
export type Env = z.infer<typeof envSchema>;
 
// Helpers
export const isDev = env.NODE_ENV === 'development';
export const isProd = env.NODE_ENV === 'production';
export const isTest = env.NODE_ENV === 'test';

Best Practices

  1. Validate at startup - Fail fast before serving requests
  2. Use Zod - Type-safe parsing and transformation
  3. Separate client/server - Never expose server vars to client
  4. Transform strings - Parse to correct types (number, boolean)
  5. Provide defaults - For non-critical optional values
  6. Document requirements - Schema serves as documentation