React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

envenvironment-variablesconfignext-publicserver-onlysecrets

Environment Variables

Recipe

Configure and safely use environment variables in Next.js 15+ with .env files, the NEXT_PUBLIC_ prefix for client exposure, runtime config, and the server-only pattern to prevent secret leakage.

Working Example

.env File Structure

# .env.local (git-ignored, local overrides)
DATABASE_URL="postgresql://user:pass@localhost:5432/mydb"
AUTH_SECRET="super-secret-key-never-expose"
 
# Client-safe variables must use NEXT_PUBLIC_ prefix
NEXT_PUBLIC_APP_URL="http://localhost:3000"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_abc123"
# .env (checked into git, shared defaults)
NEXT_PUBLIC_APP_NAME="My App"
# .env.production (production overrides)
NEXT_PUBLIC_APP_URL="https://myapp.com"

Type-Safe Environment Access

// lib/env.ts
import "server-only";
 
function requireEnv(key: string): string {
  const value = process.env[key];
  if (!value) {
    throw new Error(`Missing required environment variable: ${key}`);
  }
  return value;
}
 
export const env = {
  DATABASE_URL: requireEnv("DATABASE_URL"),
  AUTH_SECRET: requireEnv("AUTH_SECRET"),
} as const;

Client-Safe Environment Access

// lib/env-client.ts
 
export const clientEnv = {
  appUrl: process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000",
  appName: process.env.NEXT_PUBLIC_APP_NAME ?? "My App",
  stripeKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY ?? "",
} as const;

Using Zod for Validation

// lib/env.ts
import "server-only";
import { z } from "zod";
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  AUTH_SECRET: z.string().min(32),
  NODE_ENV: z.enum(["development", "production", "test"]),
});
 
export const env = envSchema.parse(process.env);
 
// lib/env-client.ts
const clientEnvSchema = z.object({
  NEXT_PUBLIC_APP_URL: z.string().url(),
  NEXT_PUBLIC_APP_NAME: z.string(),
});
 
export const clientEnv = clientEnvSchema.parse({
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME,
});

Server Component Usage

// app/dashboard/page.tsx
import { env } from "@/lib/env";
 
export default async function DashboardPage() {
  // Safe: this runs only on the server
  const data = await fetch(`${env.DATABASE_URL}/api/data`);
  return <div>{/* render data */}</div>;
}

Deep Dive

How It Works

  • Next.js loads .env files automatically in this priority order (highest wins): .env.$(NODE_ENV).local > .env.local > .env.$(NODE_ENV) > .env.
  • Only variables prefixed with NEXT_PUBLIC_ are inlined into the client bundle at build time. All other process.env references are replaced with undefined in client code.
  • process.env is not a real object in client code. Next.js performs static string replacement at build time. Dynamic access like process.env[key] will not work in client components.
  • The server-only package causes a build-time error if a module is imported from a Client Component, providing a hard guarantee that secrets stay on the server.
  • Environment variables are baked in at build time for static pages. For runtime configuration, use Route Handlers or the serverRuntimeConfig / publicRuntimeConfig in next.config.js (legacy Pages Router pattern).

Variations

Runtime Environment Variables (Docker):

// next.config.ts
import type { NextConfig } from "next";
 
const nextConfig: NextConfig = {
  // Expose server-side env at runtime (not build time)
  serverRuntimeConfig: {
    databaseUrl: process.env.DATABASE_URL,
  },
  // Expose to both server and client at runtime
  publicRuntimeConfig: {
    apiUrl: process.env.NEXT_PUBLIC_API_URL,
  },
};
 
export default nextConfig;

Using T3 Env for Full Validation:

// env.mjs
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
 
export const env = createEnv({
  server: {
    DATABASE_URL: z.string().url(),
    AUTH_SECRET: z.string().min(1),
  },
  client: {
    NEXT_PUBLIC_APP_URL: z.string().url(),
  },
  runtimeEnv: {
    DATABASE_URL: process.env.DATABASE_URL,
    AUTH_SECRET: process.env.AUTH_SECRET,
    NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  },
});

TypeScript Notes

  • Create a env.d.ts to augment ProcessEnv for autocomplete:
// env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    DATABASE_URL: string;
    AUTH_SECRET: string;
    NEXT_PUBLIC_APP_URL: string;
    NEXT_PUBLIC_APP_NAME: string;
  }
}
  • Zod-validated env objects provide full type inference with no additional type annotations.

Gotchas

  1. Dynamic property access does not work in client code. process.env[varName] will always be undefined on the client because Next.js does static string replacement, not runtime lookup.
  2. .env.local is not loaded in test environments by default. Use a .env.test.local file or load env manually in your test setup.
  3. Changing .env files requires a dev server restart. Hot reload does not pick up environment variable changes.
  4. NEXT_PUBLIC_ values are visible in the browser bundle. Never put secrets (API keys, database URLs, auth secrets) behind this prefix.
  5. Docker builds bake in env vars at build time. For runtime env vars in containers, use standalone output mode and set env vars on the running container, not in the Dockerfile RUN step.

Alternatives

ApproachProsCons
.env files with NEXT_PUBLIC_Built-in, zero configNo validation, no runtime env
Zod validation in lib/env.tsType-safe, fails fastManual setup
T3 Env (@t3-oss/env-nextjs)Full validation, client/server splitExtra dependency
next.config.js runtimeConfigTrue runtime envLegacy pattern, not App Router native
Platform env (Vercel, AWS)Secure, per-environmentVendor-specific setup

FAQs

Why does process.env[varName] return undefined in client code?
  • Next.js performs static string replacement at build time, not runtime lookup.
  • Only literal references like process.env.NEXT_PUBLIC_APP_URL are replaced.
  • Dynamic property access (e.g., process.env[key]) cannot be resolved at build time and returns undefined.
What is the loading priority order for .env files?
  • .env.$(NODE_ENV).local (highest priority)
  • .env.local
  • .env.$(NODE_ENV)
  • .env (lowest priority)
  • Higher-priority files override lower ones. .env.local is git-ignored by default.
What happens if you put a secret (like a database URL) behind the NEXT_PUBLIC_ prefix?
  • The value is inlined into the client-side JavaScript bundle at build time.
  • Anyone inspecting the browser source code or bundle can see the secret.
  • Never put API keys, database URLs, or auth secrets behind NEXT_PUBLIC_.
Why does changing a .env file require a server restart?
  • Environment variables are loaded once when the dev server starts.
  • Hot module replacement (HMR) does not re-read .env files.
  • You must stop and restart next dev for changes to take effect.
How does import "server-only" protect secrets in lib/env.ts?
  • The server-only package causes a build-time error if a Client Component imports the module.
  • This provides a hard guarantee that server-only environment variables never appear in the browser bundle.
How do you handle runtime environment variables in Docker containers?
  • NEXT_PUBLIC_ vars are always baked in at build time and cannot change at runtime.
  • Server-side vars should be set on the running container (docker run -e KEY=value).
  • Use output: "standalone" mode so process.env reads happen at runtime for server code.
Gotcha: Why is .env.local not loaded during test environments?
  • Next.js skips .env.local when NODE_ENV=test by default.
  • Use .env.test.local instead, or manually load env files in your test setup.
How do you type environment variables in TypeScript for autocomplete?
// env.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    DATABASE_URL: string;
    AUTH_SECRET: string;
    NEXT_PUBLIC_APP_URL: string;
  }
}
  • This augments the global ProcessEnv interface so process.env.DATABASE_URL gets autocomplete.
What advantage does Zod validation give over a simple requireEnv function?
  • Zod validates format (e.g., .url(), .min(32)) not just presence.
  • It provides full type inference, so the returned object is fully typed automatically.
  • Validation runs at startup, failing fast with clear error messages before any request is served.
Gotcha: What happens if you use NEXT_PUBLIC_ vars with output: "standalone" in Docker?
  • NEXT_PUBLIC_ values are statically replaced at next build time.
  • Setting them as Docker runtime env vars has no effect on client code.
  • You must rebuild the image if any NEXT_PUBLIC_ value changes.
What is T3 Env and how does it differ from a manual Zod setup?
  • T3 Env (@t3-oss/env-nextjs) provides a single createEnv call that separates server and client schemas.
  • It automatically validates that NEXT_PUBLIC_ vars are in the client section and server vars are in server.
  • A manual Zod setup requires you to handle the client/server split yourself.
How would you type the return value of a Zod-validated env object in TypeScript?
import { z } from "zod";
 
const envSchema = z.object({
  DATABASE_URL: z.string().url(),
  AUTH_SECRET: z.string().min(32),
});
 
// Type is inferred automatically:
// { DATABASE_URL: string; AUTH_SECRET: string }
export const env = envSchema.parse(process.env);
  • No manual type annotation is needed; Zod infers the type from the schema.