TidyURLs
Go to Tools
BLOG
The TidyURLsJournal

Practical guides, tutorials, and insights about URLs, SEO, web development, and online marketing. Learn, troubleshoot, and get the most of the web.

Back to the Journal
TidyURLs Journal

How to Use Zod to Validate Environment Variables in Next.js

Practical insights, clear explanations, and useful ideas for understanding the web.

October 26, 20266 min read5 topics
How to Use Zod to Validate Environment Variables in Next.js
Explore the details
#Next.js#Zod#TypeScript#Environment Variables#Web Development
October 26, 20266 min read

Environment variables are easy to mistype, omit, or confuse with values that belong in the browser. In a Next.js app, a Zod schema can check required values, provide actionable errors, and give server code inferred TypeScript types. This guide shows how to validate server-only and public variables without bundling secrets into client code.

Key Takeaways

  • Keep server secrets in a server-only module and never import that module from client components.
  • Use Zod to validate process.env and infer the validated environment type from the schema.
  • Validate at server startup; also check required public variables before building, since NEXT_PUBLIC_ values are inlined into client bundles at build time.
  • Report variable names and validation messages, not the values themselves.

Define an environment schema

Install Zod if it is not already a project dependency:

npm install zod

Create src/lib/env-schema.ts. This module defines the shape and validation behavior, but does not itself export environment values to application code.

import { z } from "zod";

export const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  AUTH_SECRET: z.string().min(32),
  NEXT_PUBLIC_API_BASE_URL: z.string().url(),
});

export function parseEnv(source: Record<string, string | undefined> = process.env) {
  const result = envSchema.safeParse(source);

  if (!result.success) {
    const details = result.error.issues
      .map(({ path, message }) => `- ${path.length ? path.join(".") : "environment"}: ${message}`)
      .join("\n");

    throw new Error(`Invalid environment variables:\n${details}`);
  }

  return result.data;
}

export type Env = z.infer<typeof envSchema>;

safeParse lets the application format a useful error rather than exposing a raw exception. The error lists the variable and what is wrong, but not its value. For example, a missing DATABASE_URL produces a message identifying that variable.

The schema requires a URL for the database setting, a secret of at least 32 characters, and a URL for the public API base. Adjust these rules to match your application: a minimum length is a basic format check, not proof that a secret was generated securely or is suitable for a particular authentication system.

Keep server-only values out of client code

Create a separate module for validated values and mark it server-only:

import "server-only";
import { parseEnv } from "./env-schema";

export const env = parseEnv();

Use env only from server-side code, such as a route handler or a server component:

import { env } from "@/lib/env";

export async function GET() {
  const response = await fetch(`${env.NEXT_PUBLIC_API_BASE_URL}/health`, {
    headers: {
      Authorization: `Bearer ${env.AUTH_SECRET}`,
    },
  });

  return Response.json({ upstreamOk: response.ok });
}

Do not import @/lib/env into a file marked with "use client", or into a module used by client components. The server-only marker helps Next.js flag an accidental client-side import. Keep secrets such as AUTH_SECRET unprefixed: any value named NEXT_PUBLIC_* is intended for browser exposure and must not contain a secret.

A client component that genuinely needs the public API base can reference it directly:

"use client";

const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL;

Next.js statically replaces direct references to NEXT_PUBLIC_* variables in client bundles during the build. Do not expect a public variable to change when the same built bundle is moved to a different runtime environment. Also avoid indirect lookups such as process.env[name] when you expect Next.js to inline a public value. If a value must be supplied at runtime, expose it through an intentional server-rendered configuration mechanism instead, and only expose values safe for users to see.

Validate at startup and before building

Importing the server-only module validates the environment when that module is evaluated. To ensure validation happens as the Node.js server starts, use a Next.js instrumentation entry point. For a project using a src directory, create src/instrumentation.ts:

export async function register() {
  if (process.env.NEXT_RUNTIME === "nodejs") {
    await import("./lib/env");
  }
}

The runtime check keeps this server-only validation on the Node.js runtime. Adapt the import path if your project does not use src. If the application runs in multiple processes, make sure each process receives the required server environment variables.

Public variables need an additional check before next build: their browser-visible values are baked into the build, so validating only when a deployed server starts is too late to catch a missing value used to produce the bundle. For example, install tsx as a development dependency and create scripts/validate-env.ts:

import { loadEnvConfig } from "@next/env";
import { parseEnv } from "../src/lib/env-schema";

loadEnvConfig(process.cwd());
parseEnv();
console.log("Environment variables are valid.");

Then run the check before building:

{
  "scripts": {
    "build": "tsx scripts/validate-env.ts && next build"
  }
}

This script uses Next's environment loader so it can read the project's .env files as well as variables supplied by the build environment. If your project does not use src, update the schema import path. Keep .env files containing secrets out of version control, and configure deployment secrets through the hosting environment's secret-management facilities.

Infer types and use validated values

The schema is the source of truth for the validated shape. z.infer<typeof envSchema> produces a TypeScript type from the schema, and parseEnv() returns the validated data with that inferred shape. In server code, importing env therefore gives typed values without writing a second interface by hand.

The type does not validate values on its own. Validation occurs when parseEnv() runs; TypeScript inference only describes the result after successful parsing. If you add a new required variable to the schema, add it to local development and deployment configuration as well.

Common Mistakes

  • Putting secrets in NEXT_PUBLIC_* variables. The prefix signals that a value may be included in client-side output. Never use it for credentials or private keys.
  • Using a server environment module in client code. Keep the validated server module marked with server-only and out of client import paths.
  • Checking public variables only at runtime. Client bundles use public values from build time. Validate those values before building and rebuild when they change.
  • Assuming TypeScript proves the environment is valid. A type annotation cannot make a missing runtime variable appear. Parse the actual environment values.
  • Printing complete environment values in errors. Error messages and build logs can be widely accessible. Show the variable name and issue, not the secret.
  • Assuming a URL check proves a destination is safe to fetch. Zod's URL validation checks input shape; it is not a network security policy. If your server fetches user-provided URLs, apply appropriate SSRF protections separately.

Best Practices

  • Keep the schema, server-only accessor, and public client references clearly separated.
  • Validate required values before the build and again when the Node.js server starts.
  • Use clear variable-specific constraints and review them when configuration changes.
  • Store secrets in deployment-managed secret stores or protected environment configuration; do not commit them.
  • Test missing and malformed values by running the validation script with intentionally incomplete configuration in a safe local environment.
  • Keep public configuration limited to values users are allowed to inspect.

Conclusion

A small Zod schema catches configuration errors early and gives server code reliable inferred types. Pair it with a server-only environment module, startup validation, and a pre-build check for public variables. Treat every NEXT_PUBLIC_* value as browser-visible, never as a place to store secrets.

References

Live retrieval of the official Zod and Next.js documentation was unavailable while preparing this article, so I could not verify current documentation pages or include source links. Check the official documentation for your installed Zod and Next.js versions, especially for instrumentation and environment-loading details.