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

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

NEXT_PUBLIC_ looks simple until you accidentally expose a secret key or wonder why your server variable is undefined on the client.

Ștefan Binisor

Ștefan Binisor

Co-founder, peal.dev

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

We've seen it happen more than once — someone pushes a new feature, the build passes, everything looks fine, and then they realize their Stripe secret key is sitting in the JavaScript bundle that every visitor downloads. Or the opposite: they've been trying to read an environment variable on the client for two hours and it keeps coming back as undefined, and the fix is embarrassingly simple.

Environment variables in Next.js aren't complicated, but the rules are specific enough that if you don't know them cold, you will make one of these mistakes. Let's fix that.

The Core Rule (Tattoo This on Your Hand)

Next.js splits environment variables into two categories based on one thing: whether the variable name starts with NEXT_PUBLIC_.

  • NEXT_PUBLIC_SOMETHING — available in both server and client code. Baked into the JavaScript bundle at build time.
  • SOMETHING (no prefix) — available only in server-side code: Server Components, Route Handlers, Server Actions, getServerSideProps, getStaticProps. Never sent to the browser.

That's it. That's the whole system. The problem is that "baked into the JavaScript bundle at build time" has real consequences that people don't think through.

What "Public" Actually Means

When you prefix a variable with NEXT_PUBLIC_, Next.js does a literal find-and-replace at build time. It replaces every reference to process.env.NEXT_PUBLIC_WHATEVER with the actual value string. This means anyone who opens DevTools and looks at your JS bundle can read it.

// This is fine — public analytics key, meant to be seen
const analyticsId = process.env.NEXT_PUBLIC_POSTHOG_KEY

// This will end your career — secret key exposed to everyone
const stripeSecret = process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY // NEVER DO THIS

The "public" in NEXT_PUBLIC_ means public to the internet. Not just public to logged-in users. Not just public to your team. Public to every person who visits your site and opens their browser console. If you're putting a database password, a secret API key, or anything you wouldn't post on Twitter in a NEXT_PUBLIC_ variable, you have a security incident waiting to happen.

Rule of thumb: if the key has the word "secret", "private", or "password" in it, or if losing control of it would cost you money or compromise your users — it should never have NEXT_PUBLIC_ prefix.

Common Mistake #1: Reading a Private Variable on the Client

You've got a variable in your .env.local, it has no NEXT_PUBLIC_ prefix, and you're trying to read it in a Client Component. It returns undefined. You add console.log statements everywhere. You restart the dev server. Nothing. Here's why:

'use client'

// This will ALWAYS be undefined. No exceptions.
export function MyComponent() {
  const apiUrl = process.env.MY_PRIVATE_API_URL // undefined
  
  return <div>{apiUrl}</div> // renders nothing
}

The fix depends on what you actually need. If the value is safe to expose publicly (like a public API endpoint), add the NEXT_PUBLIC_ prefix. If it contains secrets, move the logic to a Server Component or a Route Handler and pass down only what the client needs.

// Option 1: It's a public value, just add the prefix
// .env.local
NEXT_PUBLIC_API_URL=https://api.example.com

// Then in your client component:
const apiUrl = process.env.NEXT_PUBLIC_API_URL // works

// Option 2: It's secret, keep it server-side
// app/data/page.tsx (Server Component)
export default async function Page() {
  const data = await fetch('https://api.example.com', {
    headers: { Authorization: `Bearer ${process.env.SECRET_API_KEY}` }
  })
  const json = await data.json()
  
  // Pass only the safe data to a client component
  return <ClientComponent data={json} />
}

Common Mistake #2: The Build-Time vs Runtime Trap

This one is subtle and it burned us properly. NEXT_PUBLIC_ variables are inlined at build time. That means if you build your app and then change the environment variable, the running app still has the old value. You need to rebuild.

This matters when you deploy with Docker or any setup where you might want to change config without rebuilding. A NEXT_PUBLIC_ variable cannot be changed at runtime. The value is literally replaced in the compiled JavaScript files.

# .env.production
NEXT_PUBLIC_API_URL=https://old-api.example.com

# You build the app
next build

# Then you change the variable and restart — doesn't matter
NEXT_PUBLIC_API_URL=https://new-api.example.com

# The bundle still has https://old-api.example.com hardcoded
# You MUST run next build again

Private variables (without the prefix) are read at runtime on the server, so they can change between restarts without a rebuild. This is one reason why server-side config is more flexible for dynamic environments.

Common Mistake #3: The .env File Order of Operations

Next.js loads env files in a specific order and later files can override earlier ones. Most people don't know this hierarchy:

  • .env — base file, always loaded
  • .env.local — local overrides, never commit this to git
  • .env.development or .env.production — environment-specific
  • .env.development.local or .env.production.local — local environment-specific overrides

The most common mistake here is adding sensitive values to .env and committing it to git because you forgot it wasn't the "local only" one. Only files ending in .local are gitignored by default in Next.js. Add .env to your .gitignore manually if you're using it for non-sensitive defaults, and make a habit of treating all env files as potentially sensitive.

# .gitignore — make sure these are all in here
.env.local
.env.development.local
.env.test.local
.env.production.local

# If you use .env for anything sensitive, add this too:
.env

Common Mistake #4: Accessing env Variables with Dynamic Keys

Because NEXT_PUBLIC_ variables are inlined at build time via static analysis, you can't access them with dynamic keys. This trips people up when they try to be clever.

// This does NOT work — Next.js can't statically analyze this
const keyName = 'NEXT_PUBLIC_STRIPE_KEY'
const value = process.env[keyName] // undefined at runtime on client

// This works — static reference
const value = process.env.NEXT_PUBLIC_STRIPE_KEY // 'pk_live_...'

// Also doesn't work in client code
Object.keys(process.env) // empty object on the client

// For server-side code (no prefix), dynamic access works fine
// because it's real process.env at runtime
const serverValue = process.env[`${provider}_API_KEY`] // works on server

This is a consequence of the find-and-replace mechanism. Next.js's bundler (webpack or Turbopack) does a text scan for literal references to process.env.NEXT_PUBLIC_* and replaces them. If you build the reference dynamically, the scanner misses it.

Validating Your Environment Variables

One practice that's saved us from embarrassing production incidents: validate your environment variables at startup. Without this, a missing variable silently returns undefined and you end up with weird errors deep in your app instead of a clear message at boot time.

// 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_STRIPE_PUBLISHABLE_KEY: z.string().startsWith('pk_'),
  NEXT_PUBLIC_APP_URL: z.string().url(),
})

// Only run server validation on the server
if (typeof window === 'undefined') {
  const parsed = serverEnvSchema.safeParse(process.env)
  if (!parsed.success) {
    console.error('Invalid server environment variables:', parsed.error.format())
    throw new Error('Invalid environment variables')
  }
}

const clientParsed = clientEnvSchema.safeParse({
  NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
})

if (!clientParsed.success) {
  throw new Error('Invalid public environment variables')
}

export const env = {
  // Server-only
  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!,
  // Public
  NEXT_PUBLIC_STRIPE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!,
  NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL!,
}

Now import from lib/env.ts instead of process.env directly. You get type safety, you get clear errors when something's missing, and you catch misconfiguration before it causes silent failures. The t3-env library does this too if you want something battle-tested and pre-built.

The .env.example File You Should Always Have

Commit a .env.example file to your repo with all the variable names but no actual values. This is documentation that lives next to your code. Every variable your app needs should be listed here with a comment explaining what it is and where to get it.

# .env.example — commit this to git

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

# Auth — generate with: openssl rand -base64 32
NEXTAUTH_SECRET=
NEXTAUTH_URL=http://localhost:3000

# Stripe — get from dashboard.stripe.com/apikeys
STRIPE_SECRET_KEY=sk_test_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...

# Email
RESEND_API_KEY=re_...

# App URL
NEXT_PUBLIC_APP_URL=http://localhost:3000

This saves new team members hours of confusion. It also saves you when you're setting up a new deployment environment and you've forgotten which variables the app actually needs. We build all our peal.dev templates with a .env.example file included — it's one of those small things that makes a starter actually usable instead of just a pile of code.

Quick Reference: What Goes Where

  • Database connection strings → private (no prefix), server only
  • Secret API keys (Stripe sk_, OpenAI, etc.) → private, server only
  • Webhook secrets → private, server only
  • Auth secrets (NEXTAUTH_SECRET) → private, server only
  • Publishable/public keys (Stripe pk_, PostHog, analytics) → NEXT_PUBLIC_
  • App URL for client-side links → NEXT_PUBLIC_
  • Feature flags visible to users → NEXT_PUBLIC_
  • Third-party widget IDs (Intercom, etc.) → NEXT_PUBLIC_
When in doubt, ask: if this value leaked to a random internet user, could they run up my bill, access my database, or impersonate my app? If yes — no NEXT_PUBLIC_ prefix, ever.

Environment variables aren't glamorous but getting them wrong creates real problems — either things silently break because a value is undefined, or you wake up to a bill from someone who scraped your exposed API key out of your bundle. Neither is fun. Spend 30 minutes setting this up properly on your next project and you'll never think about it again.

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