Type-Safe Environment Configuration for Next.js: A Complete Guide
Type-safe environment variables in Next.js with Zod — separate serverEnv and clientEnv objects, build-time validation in next.config.ts, and a runtime guard that throws when serverEnv is touched in a client component.
Next.js gives you exactly one environment layer for both server-only secrets and NEXT_PUBLIC_ values, and process.env.FOO is string | undefined everywhere. Mix those two facts and you eventually ship a STRIPE_SECRET_KEY to the browser, or you guard against undefined in twelve different files.
The fix is to split the environment into two Zod schemas — one for the server, one for the client — validate them at boot, and never read process.env directly again.
You can also explore the full working example in the companion repository:
https://github.com/dev-kraken/type-safe-env-next-js
Why Environment Variables Need Type Safety
Most Next.js projects start with direct process.env access.
const apiKey = process.env.API_KEY;
const databaseUrl = process.env.DATABASE_URL;
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
This works, but it has several problems:
- Environment variables can be
undefined - All values are treated as strings
- Invalid values are not caught early
- TypeScript cannot infer safe runtime values
- Server-only secrets can accidentally be imported into client code
- Error messages are often unclear during deployment
A production-ready environment system should validate values before the app runs and clearly separate server-only variables from client-safe variables.
What This Setup Provides
This environment configuration system gives you:
- Type-safe environment access
- Runtime validation with Zod
- Build-time validation during
next build - Server-only and client-safe environment separation
- Clear error messages for missing or invalid variables
- Runtime guards that block server variables from client components
- Computed helpers such as
isDev,isProd, andisTest - A clean structure that works with Next.js App Router
Project Structure
The environment system is organized inside lib/env.
lib/env/
├── schemas.ts # Zod validation schemas
├── error.ts # Custom validation error class
├── server.ts # Server-only environment access
├── client.ts # Client-safe environment access
└── index.ts # Public exports
next.config.ts # Build-time validation
instrumentation.ts # Runtime validation
This structure keeps validation, typing, security, and public exports separated.
Install the Example Project
Clone the companion repository:
git clone https://github.com/dev-kraken/type-safe-env-next-js.git
Install dependencies:
pnpm install
Copy the example environment file:
cp .env.example .env.local
Start the development server:
pnpm dev
Step 1: Define Environment Schemas
The schema layer defines which environment variables are required and how they should be validated.
Create or update lib/env/schemas.ts.
import { z } from "zod";
const nodeEnv = z
.enum(["development", "production", "test"])
.default("development");
const requiredString = (name: string) =>
z
.string({
error: `${name} is required`,
})
.min(1, `${name} cannot be empty`);
const requiredUrl = (name: string) =>
z.url({
error: `${name} must be a valid URL`,
});
const requiredSecret = (name: string, minLength = 32) =>
z
.string({
error: `${name} is required`,
})
.min(minLength, `${name} must be at least ${minLength} characters long`);
export const serverSchema = z.object({
NODE_ENV: nodeEnv,
// Server-only secrets
COOKIE_SECRET: requiredSecret("COOKIE_SECRET", 32),
// Add your server variables here
DATABASE_URL: requiredUrl("DATABASE_URL").optional(),
STRIPE_SECRET_KEY: requiredString("STRIPE_SECRET_KEY").optional(),
});
export const clientSchema = z.object({
NODE_ENV: nodeEnv,
// Client-safe variables must use NEXT_PUBLIC_
NEXT_PUBLIC_APP_URL: requiredUrl("NEXT_PUBLIC_APP_URL"),
// Add your public variables here
NEXT_PUBLIC_API_URL: requiredUrl("NEXT_PUBLIC_API_URL").optional(),
});
export type ServerSchema = z.infer<typeof serverSchema>;
export type ClientSchema = z.infer<typeof clientSchema>;
The most important rule is simple:
Server secrets belong in serverSchema, while browser-safe values belong in clientSchema and should use the NEXT_PUBLIC_ prefix.
Step 2: Create a Custom Error Class
A custom error class makes environment validation failures easier to understand.
Create lib/env/error.ts.
import type { ZodError } from "zod";
export class EnvValidationError extends Error {
constructor(
message: string,
public readonly zodError: ZodError,
) {
super(message);
this.name = "EnvValidationError";
}
getFormattedMessage(): string {
const lines = [
"",
`❌ ${this.message}`,
"─".repeat(60),
"",
"Missing or invalid environment variables:",
"",
];
for (const issue of this.zodError.issues) {
const variable = issue.path.join(".");
lines.push(` ✗ ${variable}`);
lines.push(` └─ ${issue.message}`);
lines.push("");
}
lines.push("─".repeat(60));
lines.push("");
lines.push("Create or update your .env.local file with the required variables.");
return lines.join("\n");
}
}
Instead of vague runtime failures, developers get a clear list of missing or invalid variables.
Step 3: Create Server Environment Access
Server variables should only be available on the server.
Create lib/env/server.ts.
import { EnvValidationError } from "./error";
import { serverSchema, type ServerSchema } from "./schemas";
export type ServerEnv = ServerSchema & {
readonly isDev: boolean;
readonly isProd: boolean;
readonly isTest: boolean;
};
let cachedServerEnv: ServerEnv | null = null;
export function getServerEnv(): ServerEnv {
if (typeof window !== "undefined") {
throw new Error(
"Server environment variables cannot be accessed from the client.",
);
}
if (cachedServerEnv) {
return cachedServerEnv;
}
const result = serverSchema.safeParse(process.env);
if (!result.success) {
throw new EnvValidationError(
"Server Environment Validation Failed",
result.error,
);
}
cachedServerEnv = {
...result.data,
isDev: result.data.NODE_ENV === "development",
isProd: result.data.NODE_ENV === "production",
isTest: result.data.NODE_ENV === "test",
};
return cachedServerEnv;
}
export function resetServerEnv(): void {
cachedServerEnv = null;
}
export const serverEnv = new Proxy({} as ServerEnv, {
get(_target, prop: keyof ServerEnv) {
return getServerEnv()[prop];
},
});
This provides lazy, type-safe access to server variables and blocks usage in browser environments.
Step 4: Create Client Environment Access
Client variables must be explicitly mapped so only safe values are bundled for the browser.
Create lib/env/client.ts.
import { EnvValidationError } from "./error";
import { clientSchema, type ClientSchema } from "./schemas";
const clientEnvVars = {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
} as const;
export type ClientEnv = ClientSchema & {
readonly isDev: boolean;
readonly isProd: boolean;
readonly isTest: boolean;
};
let cachedClientEnv: ClientEnv | null = null;
export function getClientEnv(): ClientEnv {
if (cachedClientEnv) {
return cachedClientEnv;
}
const result = clientSchema.safeParse(clientEnvVars);
if (!result.success) {
throw new EnvValidationError(
"Client Environment Validation Failed",
result.error,
);
}
cachedClientEnv = {
...result.data,
isDev: result.data.NODE_ENV === "development",
isProd: result.data.NODE_ENV === "production",
isTest: result.data.NODE_ENV === "test",
};
return cachedClientEnv;
}
export function resetClientEnv(): void {
cachedClientEnv = null;
}
export const clientEnv = new Proxy({} as ClientEnv, {
get(_target, prop: keyof ClientEnv) {
return getClientEnv()[prop];
},
});
The explicit clientEnvVars object is important because it prevents accidental exposure of server-only values.
Step 5: Export a Clean Public API
Create lib/env/index.ts.
export {
serverEnv,
getServerEnv,
resetServerEnv,
type ServerEnv,
} from "./server";
export {
clientEnv,
getClientEnv,
resetClientEnv,
type ClientEnv,
} from "./client";
export {
serverSchema,
clientSchema,
type ServerSchema,
type ClientSchema,
} from "./schemas";
export { EnvValidationError } from "./error";
export function validateEnv(): void {
if (typeof window === "undefined") {
const { getServerEnv } = require("./server");
getServerEnv();
}
const { getClientEnv } = require("./client");
getClientEnv();
}
Now the rest of your application can import environment values from one place.
Step 6: Validate During Build
Environment issues should be caught before deployment.
Use next.config.ts to validate variables during the build process.
import type { NextConfig } from "next";
import { EnvValidationError, validateEnv } from "./lib/env";
try {
validateEnv();
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);
}
const nextConfig: NextConfig = {
reactStrictMode: true,
};
export default nextConfig;
Now pnpm build fails early if required environment variables are missing or invalid.
pnpm build
Step 7: Validate at Runtime
Build-time validation is helpful, but runtime validation is also useful for server environments.
Use instrumentation.ts.
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { validateEnv, EnvValidationError } = await import("./lib/env");
try {
validateEnv();
console.log("✅ Runtime environment validation successful");
} catch (error) {
if (error instanceof EnvValidationError) {
console.error(error.getFormattedMessage());
} else {
console.error("Runtime environment validation error:", error);
}
throw error;
}
}
}
This validates the environment when the server starts.
Step 8: Create .env.example
Document required environment variables in .env.example.
# Server-only variables
COOKIE_SECRET=your-super-secret-key-at-least-32-characters
# Optional server-only variables
# DATABASE_URL=postgresql://user:password@localhost:5432/database
# STRIPE_SECRET_KEY=sk_test_...
# Client-safe variables
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Optional client-safe variables
# NEXT_PUBLIC_API_URL=https://api.example.com
Never place real secrets in .env.example.
Use placeholders that explain the expected value.
Usage in Server Components
Server Components can safely access both server-only and client-safe variables.
import { clientEnv, serverEnv } from "@/lib/env";
export default function Page() {
const cookieSecret = serverEnv.COOKIE_SECRET;
const appUrl = clientEnv.NEXT_PUBLIC_APP_URL;
return (
<main>
<h1>Environment Ready</h1>
<p>App URL: {appUrl}</p>
<p>Secret configured: {cookieSecret ? "Yes" : "No"}</p>
</main>
);
}
Server-only variables should never be rendered directly into the page.
The example above is only showing that the value exists.
Usage in Client Components
Client Components should only use clientEnv.
"use client";
import { clientEnv } from "@/lib/env";
export function ClientBanner() {
return (
<div>
<p>App URL: {clientEnv.NEXT_PUBLIC_APP_URL}</p>
<p>Development mode: {clientEnv.isDev ? "Yes" : "No"}</p>
</div>
);
}
Do not import serverEnv into a Client Component.
"use client";
// ❌ Do not do this
import { serverEnv } from "@/lib/env";
The server environment layer is designed to throw an error if accessed from the browser.
Usage in API Routes
API routes and route handlers can safely access server-only variables.
import { serverEnv } from "@/lib/env";
import { NextResponse } from "next/server";
export async function GET() {
const cookieSecret = serverEnv.COOKIE_SECRET;
return NextResponse.json({
success: true,
configured: Boolean(cookieSecret),
});
}
This keeps secrets on the server while preserving type safety.
Adding a New Server Variable
To add a new server-only variable, update serverSchema.
export const serverSchema = z.object({
NODE_ENV: nodeEnv,
COOKIE_SECRET: requiredSecret("COOKIE_SECRET", 32),
DATABASE_URL: requiredUrl("DATABASE_URL"),
STRIPE_SECRET_KEY: requiredString("STRIPE_SECRET_KEY"),
});
Then add it to .env.local.
DATABASE_URL=postgresql://user:password@localhost:5432/database
STRIPE_SECRET_KEY=sk_test_example
Now you can access it safely.
import { serverEnv } from "@/lib/env";
const databaseUrl = serverEnv.DATABASE_URL;
const stripeSecretKey = serverEnv.STRIPE_SECRET_KEY;
Adding a New Client Variable
Client variables require two steps.
First, add the variable to clientSchema.
export const clientSchema = z.object({
NODE_ENV: nodeEnv,
NEXT_PUBLIC_APP_URL: requiredUrl("NEXT_PUBLIC_APP_URL"),
NEXT_PUBLIC_API_URL: requiredUrl("NEXT_PUBLIC_API_URL"),
});
Then add it to clientEnvVars in client.ts.
const clientEnvVars = {
NODE_ENV: process.env.NODE_ENV,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
} as const;
Finally, add it to .env.local.
NEXT_PUBLIC_API_URL=https://api.example.com
Then use it in your application.
import { clientEnv } from "@/lib/env";
const apiUrl = clientEnv.NEXT_PUBLIC_API_URL;
Example Error Output
If validation fails, the system prints a clear error.
❌ Server Environment Validation Failed
────────────────────────────────────────────────────────────
Missing or invalid environment variables:
✗ COOKIE_SECRET
└─ COOKIE_SECRET is required
✗ DATABASE_URL
└─ DATABASE_URL must be a valid URL
────────────────────────────────────────────────────────────
Create or update your .env.local file with the required variables.
This makes deployment issues much easier to debug.
Testing Environment Configuration
When testing, reset cached values before each test.
import { resetClientEnv, resetServerEnv } from "@/lib/env";
beforeEach(() => {
resetServerEnv();
resetClientEnv();
});
You can then test valid and invalid environment values.
import { getServerEnv, resetServerEnv } from "@/lib/env";
describe("server environment", () => {
beforeEach(() => {
resetServerEnv();
});
it("validates a correct server environment", () => {
process.env.COOKIE_SECRET = "a".repeat(32);
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
expect(() => getServerEnv()).not.toThrow();
});
it("throws when COOKIE_SECRET is missing", () => {
delete process.env.COOKIE_SECRET;
expect(() => getServerEnv()).toThrow();
});
});
Migrating from process.env
Before:
const cookieSecret = process.env.COOKIE_SECRET;
const appUrl = process.env.NEXT_PUBLIC_APP_URL;
After:
import { clientEnv, serverEnv } from "@/lib/env";
const cookieSecret = serverEnv.COOKIE_SECRET;
const appUrl = clientEnv.NEXT_PUBLIC_APP_URL;
This gives you autocomplete, validation, and safer access patterns.
Security Best Practices
Follow these rules when working with environment variables in Next.js:
- Keep secrets in
serverEnv - Only expose browser-safe values through
clientEnv - Prefix public variables with
NEXT_PUBLIC_ - Never import
serverEnvinto Client Components - Keep
.env.localout of version control - Maintain a clean
.env.example - Validate during build and runtime
- Use strong secrets with at least 32 characters
- Rotate secrets if they are accidentally exposed
Common Issues
Server Environment Accessed on the Client
This usually means serverEnv was imported inside a Client Component.
Use clientEnv instead, or move the logic to a Server Component, API route, or server action.
Missing Environment Variables During Build
If pnpm build fails, check that your deployment platform has all required variables configured.
Also confirm your local .env.local file includes the required keys.
Client Variable Is Undefined
Make sure the variable:
- Starts with
NEXT_PUBLIC_ - Exists in
clientSchema - Exists in
clientEnvVars - Exists in
.env.local - Was available when the app was built
Client environment variables are bundled at build time, so changes often require restarting the development server or rebuilding the app.
Wrap-up
Two Zod schemas, two proxies (serverEnv, clientEnv), validation in next.config.ts and instrumentation.ts, and a runtime guard that throws if anyone imports serverEnv into a client component. After that, process.env is banned in the rest of the codebase.
The full working setup, including tests, is on GitHub: dev-kraken/type-safe-env-next-js.
Related
- Type-safe environment validation in Laravel & PHP — the same idea on the PHP side, with my EnvValidator package.
- Docker Tricks in 2026 — keep secrets out of Compose files and validate them at boot instead.
- Configure Biome for JavaScript & TypeScript in VS Code — pairs well with this setup for a clean TS/Next.js base.
- Building my own realtime database layer — another deep-dive into a Next.js + Node infrastructure piece.