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 zodBasic 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 | undefinedSeparating 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
- Validate at startup - Fail fast before serving requests
- Use Zod - Type-safe parsing and transformation
- Separate client/server - Never expose server vars to client
- Transform strings - Parse to correct types (number, boolean)
- Provide defaults - For non-critical optional values
- Document requirements - Schema serves as documentation