Learn how to build a secure, type-safe environment configuration system for Next.js with Zod validation, caching, and error handling. Production-ready solution with TypeScript.
Dev Kraken
Managing environment variables in Next.js applications can be tricky, especially when dealing with sensitive data like API keys, database URLs, and authentication secrets. A single mistake—like exposing server-only variables to the client—can lead to serious security vulnerabilities.
In this comprehensive guide, I'll show you how to build a production-ready, type-safe environment configuration system for Next.js that includes:
Most Next.js developers start with simple process.env
access:
// ❌ Problems with this approach:
const apiKey = process.env.OPENAI_API_KEY; // Could be undefined
const dbUrl = process.env.DATABASE_URL; // No validation
const port = process.env.PORT; // Type is string, not number
This approach has several critical issues:
string | undefined
Our system addresses these issues through:
The system consists of five core modules:
lib/env/
├── schemas.ts # Zod schemas for validation
├── validate.ts # Validation logic & caching
├── client.ts # Client-safe environment
├── server.ts # Server-only environment
├── error.ts # Custom error handling
└── index.ts # Public API & security proxies
Start by creating validation schemas using Zod. This ensures your environment variables meet specific criteria before your application runs.
import { z } from "zod";
export const serverSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
PORT: z.coerce.number().int().positive().max(65535).optional(),
// Database
DATABASE_URL: z.url(),
// Authentication
NEXT_AUTH_SECRET: z.string().min(32),
NEXT_AUTH_URL: z.url().optional(),
// Cookie security
COOKIE_SECRET: z.string().min(32).regex(/^[A-Za-z0-9+/=_-]+$/),
});
export const clientSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]),
NEXT_PUBLIC_APP_URL: z.url(),
// Optional public configurations
NEXT_PUBLIC_API_URL: z.url().optional(),
NEXT_PUBLIC_GA_MEASUREMENT_ID: z.string()
.regex(/^G-[A-Z0-9]+$/)
.optional(),
});
export type ServerSchema = z.infer<typeof serverSchema>;
export type ClientSchema = z.infer<typeof clientSchema>;
Custom Error Messages: Each field provides context-specific error messages
const createUrlSchema = (fieldName: string) =>
z.url({
error: (issue) =>
issue.input === undefined
? `${fieldName} key is missing or value is not defined`
: `${fieldName} must be a valid URL`,
});
Automatic Type Coercion: Convert string values to appropriate types
const portSchema = z.coerce.number() // "3000" becomes 3000
.int()
.positive()
.max(65535);
The validation layer handles parsing, error formatting, and intelligent caching to prevent redundant validations.
export function validateServerEnv(): ServerSchema {
// Security check: Only validate on server
if (typeof window !== "undefined") {
throw new Error("Server environment validation cannot run on client side");
}
// Return cached result if valid
if (isCacheValid("server")) {
return validationCache.server!.data!;
}
// Validate and cache
const result = serverSchema.safeParse(process.env);
if (!result.success) {
throw new EnvValidationError("Server validation failed",
formatZodErrors(result.error)
);
}
validationCache.server = {
validated: true,
data: result.data,
timestamp: Date.now(),
};
return result.data;
}
The system uses environment-aware caching:
const CACHE_CONFIG = {
TTL: process.env.NODE_ENV === "production" ? 300_000 : 5_000,
ENABLED: process.env.NODE_ENV !== "test",
};
For long-running processes, automatic cleanup prevents memory leaks:
export function initializeMemoryCleanup(): void {
// Runs every 5 minutes
cleanupInterval = setInterval(() => {
CacheManager.clearExpired();
}, 5 * 60 * 1000);
// Cleanup on process termination
process.once("exit", cleanup);
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
}
export type ClientEnv = ClientSchema & {
readonly IS_DEVELOPMENT: boolean;
readonly IS_PRODUCTION: boolean;
readonly IS_TEST: boolean;
readonly IS_CLIENT: boolean;
readonly IS_SERVER: boolean;
};
export function getClientEnv(): ClientEnv {
const validated = validateClientEnv();
return {
...validated,
IS_DEVELOPMENT: validated.NODE_ENV === "development",
IS_PRODUCTION: validated.NODE_ENV === "production",
IS_TEST: validated.NODE_ENV === "test",
IS_CLIENT: typeof window !== "undefined",
IS_SERVER: typeof window === "undefined",
};
}
export type ServerEnv = ServerSchema & {
readonly IS_DEVELOPMENT: boolean;
readonly IS_PRODUCTION: boolean;
readonly IS_TEST: boolean;
};
export function getServerEnv(): ServerEnv {
if (typeof window !== "undefined") {
throw new Error(
"Server environment cannot be accessed on client side"
);
}
const validated = validateServerEnv();
return {
...validated,
IS_DEVELOPMENT: validated.NODE_ENV === "development",
IS_PRODUCTION: validated.NODE_ENV === "production",
IS_TEST: validated.NODE_ENV === "test",
};
}
The public API uses JavaScript Proxies to enforce security at runtime:
export const serverEnv = new Proxy({} as ServerEnv, {
get(_target, prop: string | symbol) {
// 🚨 Security guard: Prevent client-side access
if (typeof window !== "undefined") {
throw new Error(
`🚨 SECURITY VIOLATION: Attempted to access "${String(prop)}" on client!\n\n` +
"Server variables contain sensitive data and must NEVER be exposed to the browser."
);
}
const env = getServerEnv();
return env[prop as keyof typeof env];
},
});
export const clientEnv = new Proxy({} as ClientEnv, {
get(_target, prop: string | symbol) {
const env = getClientEnv();
return env[prop as keyof typeof env];
},
});
Proxies provide runtime enforcement of security rules:
Custom error formatting makes debugging environment issues trivial:
// error.ts
export class EnvValidationError extends Error {
public readonly errors: Array<{
path: string[];
message: string;
code: string;
}>;
public getFormattedMessage(): string {
const lines = [
"",
"🚨 Environment Validation Failed",
"=".repeat(60),
"",
this.message,
"",
"Missing or invalid environment variables:",
"",
];
// Group errors by variable
const errorsByVariable = new Map<string, string[]>();
for (const error of this.errors) {
const variable = error.path.join(".");
if (!errorsByVariable.has(variable)) {
errorsByVariable.set(variable, []);
}
errorsByVariable.get(variable)!.push(error.message);
}
// Display formatted errors
for (const [variable, messages] of errorsByVariable) {
lines.push(` 🔴 Variable: ${variable.toUpperCase()}`);
for (const message of messages) {
lines.push(` └─ ${message}`);
}
lines.push("");
}
return lines.join("\n");
}
}
Example Error Output:
🚨 Environment Validation Failed
============================================================
Server environment validation failed
Missing or invalid environment variables:
🔴 Variable: DATABASE_URL
└─ DATABASE_URL key is missing or value is not defined
🔴 Variable: COOKIE_SECRET
└─ COOKIE_SECRET must be at least 32 characters long
└─ COOKIE_SECRET contains invalid characters
import { serverEnv, clientEnv } from "@/lib/env";
export default async function ServerComponent() {
// ✅ Safe: Access both server and client env
const dbUrl = serverEnv.DATABASE_URL;
const appUrl = clientEnv.NEXT_PUBLIC_APP_URL;
const data = await fetch(dbUrl);
return <div>Data loaded from {appUrl}</div>;
}
"use client";
import { clientEnv } from "@/lib/env";
export default function ClientComponent() {
// ✅ Safe: Access client env
const apiUrl = clientEnv.NEXT_PUBLIC_APP_URL;
// ❌ Error: This throws security violation!
// const dbUrl = serverEnv.DATABASE_URL;
return <div>API: {apiUrl}</div>;
}
import { serverEnv } from "@/lib/env";
import { NextResponse } from "next/server";
export async function GET() {
// ✅ Safe: Full access to server environment
const secret = serverEnv.COOKIE_SECRET;
// Your API logic here
return NextResponse.json({ success: true });
}
import { EnvUtils } from "@/lib/env";
// Simple environment checks
if (EnvUtils.isDevelopment()) {
console.log("Running in development mode");
}
if (EnvUtils.isProduction()) {
enableProductionFeatures();
}
// Get comprehensive runtime info
const info = EnvUtils.getRuntimeInfo();
console.log(info);
// {
// nodeEnv: "production",
// isDevelopment: false,
// isProduction: true,
// isTest: false,
// isClient: false,
// isServer: true,
// appUrl: "https://example.com"
// }
The recommended approach is to validate environment variables during the build process using next.config.ts
. This ensures all environment issues are caught before your application even starts:
import { createJiti } from "jiti";
import type { NextConfig } from "next";
import { EnvValidationError } from "./lib/env";
const jiti = createJiti(import.meta.url);
// Validate environment before Next.js builds
jiti
.import("./lib/env/")
.then((envModule: any) => {
try {
// Trigger validation
envModule.validateAllEnv();
// Initialize memory cleanup for production
if (process.env.NODE_ENV === "production") {
envModule.initializeMemoryCleanup();
}
console.log("✅ Environment validation successful");
} catch (error) {
if (error instanceof EnvValidationError) {
console.error(error.getFormattedMessage());
} else {
console.error("Environment validation error:", error);
}
process.exit(1);
}
})
.catch((error) => {
console.error("Failed to load environment module:", error);
process.exit(1);
});
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
Jiti is a TypeScript runtime that allows you to import TypeScript files directly in Node.js without pre-compilation. This is perfect for next.config.ts
because:
npm install jiti
# or
pnpm add jiti
# or
yarn add jiti
Fail Fast: Catches configuration errors before deployment
Better CI/CD: Build fails if environment is misconfigured
Zero Runtime Overhead: Validation happens once during build
Production Safety: Memory cleanup initializes automatically
The system includes debug utilities (only in development):
import { DevEnvUtils, CacheManager } from "@/lib/env";
// Get comprehensive debug info
const debugInfo = DevEnvUtils?.getDebugInfo();
// Force cache invalidation
CacheManager.clear();
// Get cache statistics
const stats = CacheManager.getStats();
console.log(stats);
// {
// server: { cached: true, valid: true, age: 45000 },
// client: { cached: true, valid: true, age: 45000 },
// memoryUsage: { totalBytes: 1024 }
// }
Extend the system with custom helpers:
import { ClientEnvUtils, ServerEnvUtils } from "@/lib/env";
// Check if variable exists
const hasAnalytics = ClientEnvUtils.hasVariable(
"NEXT_PUBLIC_GA_MEASUREMENT_ID"
);
// Get with fallback
const apiUrl = ClientEnvUtils.getWithFallback(
"NEXT_PUBLIC_API_URL",
"https://api.default.com"
);
// Get environment info
const info = ServerEnvUtils.getEnvironmentInfo();
Always validate environment variables at application startup, not during request handling:
// ✅ Good: Validate once at startup
validateAllEnv();
// ❌ Bad: Validating on every request
app.get("/api", () => {
validateAllEnv(); // Performance hit!
});
Create separate schemas for different environments:
const developmentOnlySchema = serverSchema.extend({
DEBUG_MODE: z.enum(["true", "false"]),
MOCK_API: z.enum(["true", "false"]).optional(),
});
Don't try to work around the proxy security:
// ❌ Never do this in client components!
const getServerEnv = async () => {
const response = await fetch("/api/env");
return response.json();
};
Maintain an .env.example
file:
# .env.example
# Application
NODE_ENV=development
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Database
DATABASE_URL=postgresql://user:pass@localhost:5432/db
# Authentication
NEXT_AUTH_SECRET=your-32-character-secret-here
NEXT_AUTH_URL=http://localhost:3000
# Security
COOKIE_SECRET=another-32-character-secret-here
Let TypeScript infer types from your schemas:
// ✅ Good: Type is inferred
const env = getServerEnv();
const dbUrl = env.DATABASE_URL; // Type: string
// ❌ Unnecessary: Manual typing
const env2: ServerEnv = getServerEnv();
The caching layer significantly reduces validation overhead:
Typical memory footprint:
The system adds minimal bundle size:
import { validateServerEnv, CacheManager } from "@/lib/env";
describe("Environment Configuration", () => {
beforeEach(() => {
CacheManager.clear();
});
it("validates correct server environment", () => {
process.env.DATABASE_URL = "postgresql://localhost:5432/test";
process.env.COOKIE_SECRET = "a".repeat(32);
expect(() => validateServerEnv()).not.toThrow();
});
it("throws on invalid database URL", () => {
process.env.DATABASE_URL = "not-a-url";
expect(() => validateServerEnv()).toThrow(EnvValidationError);
});
});
import { serverEnv, clientEnv } from "@/lib/env";
describe("Environment Access", () => {
it("allows server env access in server context", () => {
expect(() => serverEnv.DATABASE_URL).not.toThrow();
});
it("allows client env access anywhere", () => {
expect(() => clientEnv.NEXT_PUBLIC_APP_URL).not.toThrow();
});
});
process.env
Before:
const apiKey = process.env.API_KEY;
const dbUrl = process.env.DATABASE_URL;
After:
import { serverEnv } from "@/lib/env";
const apiKey = serverEnv.API_KEY;
const dbUrl = serverEnv.DATABASE_URL;
Before:
import { createEnv } from "@t3-oss/env-nextjs";
export const env = createEnv({
server: { DATABASE_URL: z.string().url() },
client: { NEXT_PUBLIC_APP_URL: z.string().url() },
runtimeEnv: process.env,
});
After:
// Update schemas.ts with your variables
// Use serverEnv and clientEnv imports
import { serverEnv, clientEnv } from "@/lib/env";
1. "Server environment validation cannot run on client side"
clientEnv
or move logic to server component2. "Environment validation failed"
.env
file has all required variables3. Cache not clearing in development
CacheManager.clear()
manuallyEnable detailed logging:
if (process.env.NODE_ENV === "development") {
const stats = CacheManager.getStats();
console.log("Cache stats:", stats);
const state = DevUtils?.getValidationState();
console.log("Validation state:", state);
}
This type-safe environment configuration system provides:
✅ Security: Runtime guards prevent sensitive data exposure
✅ Type Safety: Full TypeScript integration with autocomplete
✅ Performance: Intelligent caching reduces overhead
✅ Developer Experience: Clear errors and helpful utilities
✅ Production Ready: Memory optimization and error handling
The system scales from small projects to enterprise applications while maintaining zero-compromise security and developer experience.
Consider extending this system with: