50% off SaaS Starter Kit — only for the first 100 buildersGrab it →
← Back to blog
next.jsMay 24, 2026·8 min read

Next.js Environment Variables: Public vs Private, and the Mistakes That'll Burn You

NEXT_PUBLIC_ looks innocent until your secret key ends up in the browser bundle. Here's how env vars actually work in Next.js and what trips everyone up.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

Next.js Environment Variables: Public vs Private, and the Mistakes That'll Burn You

We've seen this pattern more times than we'd like to admit: someone ships a Next.js app, opens DevTools, clicks on a JS bundle, and finds their database connection string sitting there in plain text. Not because they're careless — because Next.js env var behavior is genuinely confusing the first time you encounter it, and the error messages don't help.

Here's everything you need to know about environment variables in Next.js — how the public/private split works, which files load when, and the mistakes that have caused real production incidents (including ours).

The core rule: NEXT_PUBLIC_ is the on/off switch for browser exposure

Next.js has a dead-simple rule that trips people up constantly. Any environment variable prefixed with NEXT_PUBLIC_ gets inlined into the client-side JavaScript bundle at build time. Everything else stays server-only.

When we say "inlined at build time" — we mean it literally. Next.js does a string replacement during the build. Your variable isn't looked up at runtime in the browser; it's baked directly into the JS bundle as a hardcoded string. This has consequences.

// ✅ Safe to use anywhere — server AND client
const apiUrl = process.env.NEXT_PUBLIC_API_URL

// ✅ Only safe in server-side code (Server Components, API routes, Server Actions)
const dbUrl = process.env.DATABASE_URL
const stripeSecret = process.env.STRIPE_SECRET_KEY

// ❌ This will be undefined on the client — Next.js won't expose it
console.log(process.env.DATABASE_URL) // undefined in browser

The naming convention is the entire security model. There's no other configuration. If you prefix it with NEXT_PUBLIC_, it's public. Full stop.

Which .env files load when (and why it matters)

Next.js supports multiple .env files and loads them in a specific order of precedence. Getting this wrong is how you end up with staging credentials hitting production or test config leaking into your local dev.

  • .env — base config, loaded in all environments, commit this if it only has non-secret defaults
  • .env.local — local overrides, never committed to git, highest precedence in dev
  • .env.development — loaded when NODE_ENV=development
  • .env.production — loaded when NODE_ENV=production
  • .env.test — loaded when NODE_ENV=test (note: .env.local is NOT loaded in test mode)
  • .env.development.local, .env.production.local — local overrides for specific environments

The precedence order (highest to lowest): .env.{environment}.local → .env.local → .env.{environment} → .env. Later-defined files get overridden by earlier ones. So .env.local beats .env every time.

Rule of thumb: .env.local is for secrets on your machine. .env is for safe defaults you're fine committing. Never put API keys or database URLs in .env and commit them — even in private repos. Someone will eventually make that repo public, or your git history will haunt you.

The build-time inlining trap

Here's the behavior that catches people off guard when they're deploying to Vercel or any CI/CD setup: NEXT_PUBLIC_ variables are embedded into your bundle when `next build` runs. Not when your server starts. Not at runtime. At build time.

This means if you change a NEXT_PUBLIC_ variable in your hosting environment and don't rebuild — nothing changes for your users. The old value is still baked into the bundle they download.

# This is what Next.js effectively does with NEXT_PUBLIC_ vars during build:
# Before build:
# process.env.NEXT_PUBLIC_API_URL = 'https://api.example.com'

# After build, in your bundle:
# const apiUrl = 'https://api.example.com'  // hardcoded string, not a variable

# So if you update the env var in Vercel dashboard without redeploying:
# Your users still get the old URL

For server-only variables (no NEXT_PUBLIC_ prefix), the opposite is true — they're read at runtime from the actual process environment. So you CAN update a database URL in your hosting config and restart the server without rebuilding. This distinction matters a lot for feature flags, API endpoints that change, and anything dynamic.

Common mistakes we've made (and seen)

Let's go through the real mistakes, not the theoretical ones from documentation.

**Mistake 1: Using a private variable in a Client Component.** You define DATABASE_URL in .env.local, then accidentally reference it inside a component that has 'use client' at the top. It returns undefined, your code silently fails, and you spend 45 minutes debugging before realizing what happened.

// ❌ This component is client-side — DATABASE_URL will be undefined
'use client'

export function SomeComponent() {
  // This will be undefined. No error, no warning. Just silent failure.
  const dbUrl = process.env.DATABASE_URL
  
  return <div>{dbUrl}</div> // renders empty
}

// ✅ If you need config on the client, it must be NEXT_PUBLIC_
// Or better: fetch it from an API route / Server Action

**Mistake 2: Accidentally committing .env.local.** You create .env.local with real credentials, your .gitignore has .env.local in it, but you run `git add .` and something goes wrong — maybe you added it before the .gitignore entry was there. Your Stripe secret key is now in git history forever. We've seen this. Always check `git status` before committing, and run `git rm --cached .env.local` if you catch it before pushing.

**Mistake 3: Using NEXT_PUBLIC_ for secrets because the variable is needed client-side.** This is the worst one. You need to call a third-party API from the browser, the SDK requires an API key, and without thinking too hard you slap NEXT_PUBLIC_ on it. Now that key is public. For any API that has a concept of restricted keys or domain-locked keys, use those. For everything else: proxy through your own API route.

// ❌ Never do this — OPENAI_API_KEY is now public in your bundle
const openai = new OpenAI({ apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY })

// ✅ Create an API route that handles the secret server-side
// app/api/generate/route.ts
import { NextRequest, NextResponse } from 'next/server'
import OpenAI from 'openai'

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) // no NEXT_PUBLIC_

export async function POST(req: NextRequest) {
  const { prompt } = await req.json()
  
  const completion = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [{ role: 'user', content: prompt }]
  })
  
  return NextResponse.json({ text: completion.choices[0].message.content })
}

**Mistake 4: Forgetting that Server Actions are server-side.** This is the happier mistake — people assume Server Actions might expose env vars because they're called from the client. They don't. Server Actions run on the server, so you can safely use DATABASE_URL, STRIPE_SECRET_KEY, and any other private variable inside them.

Validating env vars at startup (stop silent failures)

Undefined environment variables are silent killers. Your app starts up fine, everything looks normal, and then at 2am on a Tuesday some edge case hits the code path that uses the missing variable and your users get a cryptic error. We've been there.

The fix is validating your environment variables at startup, before your app does anything. The cleanest way to do this in a Next.js project is with a small validation file using Zod (which you probably already have installed for form validation).

// lib/env.ts
import { z } from 'zod'

const serverEnvSchema = z.object({
  DATABASE_URL: z.string().url(),
  STRIPE_SECRET_KEY: z.string().startsWith('sk_'),
  RESEND_API_KEY: z.string().min(1),
  NEXTAUTH_SECRET: z.string().min(32),
})

const clientEnvSchema = z.object({
  NEXT_PUBLIC_APP_URL: z.string().url(),
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
})

// Validate server env (only runs server-side)
export const serverEnv = serverEnvSchema.parse({
  DATABASE_URL: process.env.DATABASE_URL,
  STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
  RESEND_API_KEY: process.env.RESEND_API_KEY,
  NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
})

// Validate client env (safe to import anywhere)
export const clientEnv = clientEnvSchema.parse({
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
})

// Usage: import { serverEnv } from '@/lib/env'
// serverEnv.DATABASE_URL — type-safe, validated, throws early if missing

Now if you deploy without setting STRIPE_SECRET_KEY, your app crashes on boot with a clear error instead of silently serving broken pages. Crash early, crash loudly.

Don't import serverEnv in Client Components — even though the validation might work, you'd be attempting to read server-only process.env values in the browser. Keep serverEnv to Server Components, API routes, and Server Actions only.

The .env.example file you should always maintain

Here's the thing about environment variables that documentation always glosses over: they're tribal knowledge. You set them up once, add them to your hosting config, and then three months later you're onboarding someone new or setting up a second environment and you have absolutely no idea what variables the app needs.

The solution is embarrassingly simple: maintain a .env.example file with every variable your app needs, fake values or empty strings, and commit it to git.

# .env.example — commit this, never put real values here

# Database
DATABASE_URL=postgresql://user:password@localhost:5432/mydb

# Auth
NEXTAUTH_SECRET=your-secret-here-minimum-32-chars
NEXTAUTH_URL=http://localhost:3000

# Payments
STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

# Email
RESEND_API_KEY=re_...

# App
NEXT_PUBLIC_APP_URL=http://localhost:3000

Add a step in your README: "Copy .env.example to .env.local and fill in the values." This is the kind of boring infrastructure that saves hours of confusion. The templates we build at peal.dev all ship with a complete .env.example — it's one of those small things that makes the difference between a template that takes 10 minutes to set up and one that takes 2 hours.

Quick reference: what goes where

  • Database URLs (Postgres, MySQL, etc.) → server-only, no prefix
  • Auth secrets (NEXTAUTH_SECRET, JWT secrets) → server-only, no prefix
  • Payment secret keys (Stripe sk_, etc.) → server-only, no prefix
  • Payment publishable keys (Stripe pk_, etc.) → NEXT_PUBLIC_ is fine, they're designed to be public
  • Third-party API keys that have rate limits or cost money → server-only, proxy through API routes
  • Your own app's public URL → NEXT_PUBLIC_APP_URL is fine
  • Feature flag keys for analytics-only SDKs (PostHog, etc.) → NEXT_PUBLIC_ is usually fine, check the SDK docs
  • OAuth client secrets → server-only, no prefix
  • OAuth client IDs → depends on the OAuth flow, but usually NEXT_PUBLIC_ is fine

When in doubt, ask yourself: "If a user opens DevTools and searches my JS bundle for this string, would I be upset?" If yes, it should not have NEXT_PUBLIC_.

Environment variables are one of those things that seem trivial until they're not. The rules are simple but the edge cases — build-time inlining, silent undefined returns in Client Components, the specific load order of .env files — are the things that cause real production bugs. Set up validation early, maintain your .env.example file, and never trust that something is secret just because you didn't explicitly expose it.

Newsletter

Liked this post? There's more where it came from.

Dev guides, honest build stories, and the occasional 2am debugging confession — straight to your inbox. No spam, unsubscribe anytime.

Browse templates
Written by humansWeekly dropsSubscriber perks

Join the Discord

Ask questions, share builds, get help from founders