Fetching latest headlines…
Next.js Environment Variables: The Complete Guide (2026)
NORTH AMERICA
🇺🇸 United StatesJune 29, 2026

Next.js Environment Variables: The Complete Guide (2026)

1 views0 likes0 comments
Originally published byDev.to

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

Comments (0)

Sign in to join the discussion

Be the first to comment!