All insights
Engineering6 min read

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):

code
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):

code
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):

  1. .env — base values, safe to commit
  2. .env.local — local overrides, never commit (add to .gitignore)
  3. .env.development or .env.production — environment-specific values
  4. .env.development.local or .env.production.local — local environment-specific overrides

In practice, keep the pattern simple:

code
.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:

code
// 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:

code
// 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:

code
// 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)
code
// 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

code
// ✅ 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:

code
// 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.

Stay informed

Get our monthly deep dives.

Engineering, design, and growth insights — once a month. No spam.

Browse all resources