Next.js Environment Variables: The Complete Guide
How Next.js handles environment variables — server vs client exposure, .env file loading order, runtime vs build-time values, and common mistakes to avoid.
The Two Types
Next.js draws a hard line between server-only and client-exposed environment variables.
Server-only (no prefix):
DATABASE_URL=postgresql://...
STRIPE_SECRET_KEY=sk_live_...
OPENAI_API_KEY=sk-...
These are available in Server Components, Route Handlers, and middleware.ts. They are never included in the JavaScript bundle sent to browsers.
Client-exposed (NEXT_PUBLIC_ prefix):
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_live_...
NEXT_PUBLIC_GA_ID=G-...
NEXT_PUBLIC_APP_URL=https://yourapp.com
These are inlined at build time into the browser bundle. Anyone can read them by inspecting your page source. Never put secrets here.
File Loading Order
Next.js loads .env files in this order (later files override earlier ones):
.env— base values, safe to commit.env.local— local overrides, never commit (add to.gitignore).env.developmentor.env.production— environment-specific values.env.development.localor.env.production.local— local environment-specific overrides
In practice, keep the pattern simple:
.env ← defaults (committed)
.env.local ← local secrets (gitignored)
And in CI/production, inject secrets via your platform's secret management (Vercel Environment Variables, AWS Secrets Manager, etc.) — not via committed .env.production files.
Runtime vs Build-Time
NEXT_PUBLIC_ variables are replaced at build time — they're statically inlined. This means:
// This gets compiled to: console.log("G-ABC123")
console.log(process.env.NEXT_PUBLIC_GA_ID)
If you change a NEXT_PUBLIC_ variable after building, you must rebuild. There's no way to swap it at runtime without a redeploy.
Server-side variables are read at request time in dynamic routes, but at build time in static routes. This is a subtle but important distinction:
// Static generation — DATABASE_URL is read at build time
export async function generateStaticParams() {
const db = getDb(process.env.DATABASE_URL!) // read at build
return db.getPosts().then(posts => posts.map(p => ({ slug: p.slug })))
}
// Dynamic route — DATABASE_URL is read at request time
export default async function Page({ params }) {
const db = getDb(process.env.DATABASE_URL!) // read per-request
return db.getPost(params.slug)
}
Validating Environment Variables
Don't let missing env vars cause confusing runtime errors deep inside your application. Validate at startup:
// lib/env.ts
import { z } from 'zod'
const envSchema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
OPENAI_API_KEY: z.string().startsWith('sk-'),
NEXT_PUBLIC_APP_URL: z.string().url(),
})
export const env = envSchema.parse(process.env)
// next.config.ts
import './lib/env' // runs at startup — throws if vars are missing
export default {}
Now a missing DATABASE_URL causes an immediate, clear error at server start, not a cryptic failure on the first database query.
Accessing Variables in Different Contexts
// ✅ Server Component — access any variable
async function ServerPage() {
const data = await fetch(process.env.INTERNAL_API_URL!)
}
// ✅ Route Handler — access any variable
export async function GET() {
const key = process.env.STRIPE_SECRET_KEY!
}
// ✅ Client Component — only NEXT_PUBLIC_
'use client'
function ClientComponent() {
const gaId = process.env.NEXT_PUBLIC_GA_ID // fine
const secret = process.env.DATABASE_URL // undefined — never exposed
}
// ✅ Middleware — access server vars
export function middleware(request: NextRequest) {
const secret = process.env.AUTH_SECRET!
}
Typing process.env
TypeScript's process.env types every key as string | undefined. For variables you've validated, tell TypeScript they exist:
// types/env.d.ts
declare namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string
STRIPE_SECRET_KEY: string
OPENAI_API_KEY: string
NEXT_PUBLIC_APP_URL: string
NEXT_PUBLIC_GA_ID: string
}
}
Or use the typed env object from the Zod validation above — that's the cleaner approach.
Common Mistakes
1. Putting secrets in NEXT_PUBLIC_ variables. They end up in the browser bundle. Your STRIPE_SECRET_KEY should never start with NEXT_PUBLIC_.
2. Committing .env.local. Add it to .gitignore. Use your CI platform's secret management for production values.
3. Expecting NEXT_PUBLIC_ changes to take effect without a rebuild. They're baked in at build time.
4. Forgetting to add variables to Vercel/your platform. Local .env.local works locally but doesn't deploy. Add every required variable to your deployment platform's environment settings.
5. Using process.env.VARIABLE ?? 'default' everywhere. Validate once at startup with Zod and throw clearly — don't silently fall back to defaults that mask configuration errors.
A solid environment variable setup prevents an entire class of "works locally, fails in production" bugs that waste engineering time at the worst possible moment.
Continue reading
Related articles
Rate Limiting in Next.js: Protecting Your API Routes
How to implement production-grade rate limiting in Next.js — with Middleware-level protection, per-user limits, and distributed rate limiting using Upstash Redis.
EngineeringNext.js Parallel Routes and Intercepting Routes: A Complete Guide
Parallel routes and intercepting routes are among the most powerful App Router primitives. This guide explains what they do, when to use them, and how to avoid the common pitfalls.
EngineeringVercel vs Netlify vs AWS Amplify for Next.js in 2026
A practical comparison of the three most common Next.js hosting platforms — Vercel, Netlify, and AWS Amplify — with real cost and capability trade-offs.
Stay informed
Get our monthly deep dives.
Engineering, design, and growth insights — once a month. No spam.
Browse all resources