//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
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.
# .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"// 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;// 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;// 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,
});// 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>;
}.env files automatically in this priority order (highest wins): .env.$(NODE_ENV).local > .env.local > .env.$(NODE_ENV) > .env.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.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.serverRuntimeConfig / publicRuntimeConfig in next.config.js (legacy Pages Router pattern).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,
},
});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;
}
}process.env[varName] will always be undefined on the client because Next.js does static string replacement, not runtime lookup..env.local is not loaded in test environments by default. Use a .env.test.local file or load env manually in your test setup..env files requires a dev server restart. Hot reload does not pick up environment variable changes.NEXT_PUBLIC_ values are visible in the browser bundle. Never put secrets (API keys, database URLs, auth secrets) behind this prefix.standalone output mode and set env vars on the running container, not in the Dockerfile RUN step.| Approach | Pros | Cons |
|---|---|---|
.env files with NEXT_PUBLIC_ | Built-in, zero config | No validation, no runtime env |
Zod validation in lib/env.ts | Type-safe, fails fast | Manual setup |
T3 Env (@t3-oss/env-nextjs) | Full validation, client/server split | Extra dependency |
next.config.js runtimeConfig | True runtime env | Legacy pattern, not App Router native |
| Platform env (Vercel, AWS) | Secure, per-environment | Vendor-specific setup |
process.env.NEXT_PUBLIC_APP_URL are replaced.process.env[key]) cannot be resolved at build time and returns undefined..env.$(NODE_ENV).local (highest priority).env.local.env.$(NODE_ENV).env (lowest priority).env.local is git-ignored by default.NEXT_PUBLIC_..env files.next dev for changes to take effect.server-only package causes a build-time error if a Client Component imports the module.NEXT_PUBLIC_ vars are always baked in at build time and cannot change at runtime.docker run -e KEY=value).output: "standalone" mode so process.env reads happen at runtime for server code..env.local when NODE_ENV=test by default..env.test.local instead, or manually load env files in your test setup.// env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
AUTH_SECRET: string;
NEXT_PUBLIC_APP_URL: string;
}
}ProcessEnv interface so process.env.DATABASE_URL gets autocomplete..url(), .min(32)) not just presence.NEXT_PUBLIC_ values are statically replaced at next build time.NEXT_PUBLIC_ value changes.@t3-oss/env-nextjs) provides a single createEnv call that separates server and client schemas.NEXT_PUBLIC_ vars are in the client section and server vars are in server.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);AUTH_SECRET safelyReviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥