Environment variables in Next.js have more rules than most frameworks — and breaking them costs you either a runtime crash or a leaked secret. The NEXT_PUBLIC_ prefix, the .env file hierarchy, the build-time vs runtime distinction, the Docker baking problem — every one of these has caught developers in production.
The Two Environments: Build Time vs Runtime
The critical point: NEXT_PUBLIC_ variables are baked into the bundle at build time — they're not read from the environment at runtime. If you change them, you need to rebuild.
# NEXT_PUBLIC_ vars are embedded as string literals during `next build`
NEXT_PUBLIC_API_URL=https://api.myapp.com
# After build, the bundle literally contains: const apiUrl = "https://api.myapp.com"
Server-side variables (DATABASE_URL, API_SECRET) are read from the process environment at request time — no rebuild needed.
The .env File Hierarchy
.env # Shared defaults — committed to git
.env.local # Local overrides — NEVER committed
.env.development # Dev-specific — committed
.env.production # Production defaults — committed
For most projects, you only need two:
# .env (committed — safe defaults)
DATABASE_URL=postgresql://localhost:5432/myapp
NEXT_PUBLIC_APP_NAME=MyApp
# .env.local (not committed — real secrets)
DATABASE_URL=postgresql://user:realpassword@prod-host:5432/myapp
NEXTAUTH_SECRET=your-real-secret-here
NEXT_PUBLIC_: The Client-Side Prefix
# Available in the browser
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
NEXT_PUBLIC_APP_URL=https://myapp.com
# Server-only (no prefix)
STRIPE_SECRET_KEY=sk_live_...
DATABASE_URL=postgresql://...
'use client'
// ❌ Returns undefined in the browser — no NEXT_PUBLIC_ prefix
const secret = process.env.MY_SECRET
// ✅ Works — prefixed and baked into the bundle
const appUrl = process.env.NEXT_PUBLIC_APP_URL
Type-Safe Env with t3-env
Raw process.env returns string | undefined for everything. t3-env validates at startup and gives you full TypeScript types:
npm install @t3-oss/env-nextjs zod
// env.ts
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
},
client: {
NEXT_PUBLIC_APP_URL: z.string().url(),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
},
})
If a required variable is missing, the app throws at startup:
❌ Invalid environment variables:
- DATABASE_URL: Invalid url
- NEXTAUTH_SECRET: String must contain at least 32 character(s)
Use env.DATABASE_URL instead of process.env.DATABASE_URL everywhere — typed, validated, autocomplete works.
Manual Zod Validation (Without t3-env)
// lib/env.ts
import { z } from 'zod'
const serverSchema = z.object({
DATABASE_URL: z.string().url(),
NEXTAUTH_SECRET: z.string().min(32),
NODE_ENV: z.enum(['development', 'test', 'production']),
})
export const serverEnv = serverSchema.parse(process.env)
Vercel Environment Variable Management
Vercel has three environments — Development, Preview, Production — each with separate values.
# Sync Vercel env vars to your local machine
vercel env pull .env.local
# Add a secret to production only
vercel env add DATABASE_URL production
# List all configured variables
vercel env ls
Set staging database URLs for Preview deployments so PRs don't hit production data:
vercel env add DATABASE_URL preview
# Enter your staging database URL
The Docker Build-Time Problem
NEXT_PUBLIC_ vars are baked in at build time. Passing them at docker run time is too late:
# ❌ Won't work — NEXT_PUBLIC_API_URL is already "" in the bundle
docker run -e NEXT_PUBLIC_API_URL=https://api.myapp.com myapp
Solution: build args
FROM node:20-alpine AS builder
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
COPY . .
RUN npm run build
docker build --build-arg NEXT_PUBLIC_API_URL=https://api.myapp.com -t myapp .
Common Mistakes
Destructuring process.env — Next.js can't statically analyze it:
// ❌ Doesn't work
const { NEXT_PUBLIC_API_URL } = process.env
// ✅
const apiUrl = process.env.NEXT_PUBLIC_API_URL
No validation — silent undefined in production:
// ❌ Fails silently
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
// ✅ Fails at startup with clear error
import { env } from '@/env'
const stripe = new Stripe(env.STRIPE_SECRET_KEY)
Quick Reference
# Pattern | Browser | Server | Baked at build?
# NEXT_PUBLIC_X | ✅ | ✅ | ✅ yes
# X (no prefix) | ❌ | ✅ | ❌ read at runtime
# .env precedence (later overrides earlier)
.env → .env.local → .env.[mode] → .env.[mode].local
# Best practice
.env # Committed — safe defaults
.env.local # Not committed — real secrets
# Type-safe access
import { env } from '@/env'
env.DATABASE_URL // validated + typed
# Vercel CLI
vercel env pull .env.local
vercel env add KEY production
The main rule: never put secrets in NEXT_PUBLIC_ (they end up in the browser bundle), never access server-only vars in Client Components (they return undefined), and always validate at startup.
Full article at stacknotice.com/blog/nextjs-env-variables-guide-2026
United States
NORTH AMERICA
Related News

Master Local Fine-Tuning with "gemma-trainer"
8h ago
Building a Plugin-Free Newsletter Popup on WordPress: Custom REST Endpoint Mailchimp API v3
8h ago
ภาษาโปรแกรมมิ่งที่ syntax ง่าย ทำให้ AI หลอนน้อยลง จริงหรือ?
8h ago
How I Built a File-Timestamp-Based Feedback Loop to Enforce AI Output Quality
8h ago
GitHub Trending Digest — 2026-07-07
9h ago