Secrets and Environment Variables
IntermediateNEXT_PUBLIC_ is a publication decision, not a naming convention. Everything else is about making a wrong import fail the build instead of leaking.
Overview
Next's environment model is simple and unforgiving: a variable prefixed `NEXT_PUBLIC_` is substituted into the client bundle at build time and is therefore public forever, in every deployed bundle, for everyone. Unprefixed variables stay on the server. The danger is not the rule, it is the gap — a server module imported by a client component drags its secrets across the boundary, and nothing warns you. The `server-only` package closes that gap by turning the mistake into a build failure.
The Two Kinds
Public means published, and it is baked in at build time.
# .env.local — never committed
DATABASE_URL=postgres://… # server only
JWT_SECRET=… # server only
RAZORPAY_KEY_SECRET=… # server only
OPENAI_API_KEY=… # server only
NEXT_PUBLIC_API_URL=https://api.aicancode.org # public, by design
NEXT_PUBLIC_RAZORPAY_KEY_ID=rzp_live_… # publishable key
// Server component / action / route handler — everything is readable
const url = process.env.DATABASE_URL
// Client component — only the prefixed ones exist
process.env.NEXT_PUBLIC_API_URL // fine
process.env.JWT_SECRET // undefined, by design
// Two consequences people miss:
// 1. NEXT_PUBLIC_ values are INLINED at build time, so changing one
// needs a rebuild, not a restart — and the old value stays in
// every previously deployed bundle.
// 2. A secret placed there is not "leaked if someone looks". It is
// published. Rotate it; you cannot unpublish it.
// Validate at startup so a missing variable fails the build rather
// than the first request:
export const env = z.object({
DATABASE_URL: z.string().url(),
JWT_SECRET: z.string().min(32),
}).parse(process.env)Guarding the Boundary
server-only, and the import chain that leaks without it.
// lib/db.ts
import 'server-only'
export const db = new PrismaClient()
// Now this fails the BUILD rather than shipping a database URL:
'use client'
import { db } from '@/lib/db'
// "You're importing a component that needs server-only"
// The leak it prevents is indirect and easy to miss:
// lib/config.ts exports both PUBLIC_URL and STRIPE_SECRET
// Header.tsx 'use client', imports PUBLIC_URL from it
// -> the whole module is bundled, secret included
// So: separate modules for public and secret config, and mark the
// secret one server-only.
// The mirror image, for browser-API modules:
import 'client-only'
// React's taint API marks a value so it cannot cross the boundary:
import { experimental_taintObjectReference as taint } from 'react'
taint('Do not pass the full user to the client', user)
// And remember props to a client component are embedded in the HTML.
// Select fields explicitly:
<Profile user={{ id: user.id, name: user.name }} /> // not {...user}Where They Live Per Environment
Files, platform settings, and the rule about rotation.
.env # committed defaults, non-secret only
.env.local # gitignored, your real values
.env.production # committed, non-secret production values
.env.example # COMMITTED — every key with a placeholder.
# This is the onboarding document.
# Vercel: separate values per environment
vercel env add DATABASE_URL production
# Production, Preview and Development are distinct sets — preview
# builds must point at staging, never at the production database.
# Rules that prevent the common incidents:
# - .env* in .gitignore except .env.example
# - separate credentials per environment
# - never paste a secret into chat, a ticket, or a log line
# - a leaked secret is ROTATED, not deleted from history — assume it
# was captured the moment it was exposed
# - never put credentials in a URL query string; they end up in
# access logs, referrers and error trackers
# Detection, because this happens to everyone eventually:
gitleaks detect # in CI and as a pre-commit hookKey Points to Remember
- 1NEXT_PUBLIC_ variables are inlined at build time and remain in every shipped bundle — the prefix is a publication decision
- 2A client component importing a module that also exports secrets bundles those secrets too
- 3server-only turns that mistake into a build failure; client-only does the mirror image
- 4Props passed to client components are embedded in the HTML, so select fields rather than spreading objects
- 5Validate the environment at startup, keep per-environment credentials, and rotate a leaked secret rather than scrubbing history
Interview Questions
Sign in to ask AriaWhat exactly does the NEXT_PUBLIC_ prefix do?
How can a secret leak into the client bundle without being prefixed?
An API key was committed and removed in a later commit. Is that enough?
Ask Aria about Secrets and Environment Variables
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.