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
.envfiles 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 otherprocess.envreferences are replaced withundefinedin client code. process.envis not a real object in client code. Next.js performs static string replacement at build time. Dynamic access likeprocess.env[key]will not work in client components.- The
server-onlypackage 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/publicRuntimeConfiginnext.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.tsto augmentProcessEnvfor 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
- Dynamic property access does not work in client code.
process.env[varName]will always beundefinedon the client because Next.js does static string replacement, not runtime lookup. .env.localis not loaded intestenvironments by default. Use a.env.test.localfile or load env manually in your test setup.- Changing
.envfiles 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.- Docker builds bake in env vars at build time. For runtime env vars in containers, use
standaloneoutput mode and set env vars on the running container, not in the DockerfileRUNstep.
Alternatives
| 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 |
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_URLare replaced. - Dynamic property access (e.g.,
process.env[key]) cannot be resolved at build time and returnsundefined.
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.localis 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
.envfiles. - You must stop and restart
next devfor changes to take effect.
How does import "server-only" protect secrets in lib/env.ts?
- The
server-onlypackage 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 soprocess.envreads happen at runtime for server code.
Gotcha: Why is .env.local not loaded during test environments?
- Next.js skips
.env.localwhenNODE_ENV=testby default. - Use
.env.test.localinstead, 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
ProcessEnvinterface soprocess.env.DATABASE_URLgets 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 atnext buildtime.- 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 singlecreateEnvcall that separates server and client schemas. - It automatically validates that
NEXT_PUBLIC_vars are in theclientsection and server vars are inserver. - 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.
Related
- Authentication - storing
AUTH_SECRETsafely - Deployment - environment variables in Docker and production
- API Route Handlers - accessing env vars in Route Handlers
- Next.js Environment Variables Docs