On this page
Every app has two kinds of configuration: values that are safe for anyone to read (a public analytics ID, a feature flag) and values that grant access if they leak (a database password, a Stripe secret key, an API token). Next.js draws a hard line between these two, and the line is one prefix: NEXT_PUBLIC_. Get the prefix right and your secrets stay on the server. Get it wrong and you ship a credential to every visitor's browser, baked into a JavaScript file they can open in DevTools.
This page walks you through loading env files, the exact mechanics of what crosses the server-client boundary, reading secrets safely, and the git hygiene that keeps a real key out of your history. Secure by default starts here.
Create your .env files and understand load order
Next.js loads variables from .env* files into process.env automatically. No package, no import, no config. Create a base file at the project root:
DB_HOST=localhost
DB_USER=myuser
DB_PASS=mypasswordNow create a local override. This is where your real, machine-specific values go:
DB_PASS=the_actual_password_for_my_machineNext.js resolves a variable by walking these locations in order and stopping at the first match:
process.env(already set in the shell or the host platform).env.$(NODE_ENV).local.env.local(skipped whenNODE_ENVistest).env.$(NODE_ENV).env
So if NODE_ENV is development and DB_PASS is defined in both .env.local and .env, the value from .env.local wins. The base .env holds safe defaults that work for everyone; .env.local holds the real values for your machine and overrides them.
You can also reference one variable from another with $:
TWITTER_USER=nextjs
TWITTER_URL=https://x.com/$TWITTER_USERHere process.env.TWITTER_URL resolves to https://x.com/nextjs. If you need a literal $ in a value, escape it as \$.
Understand exactly what NEXT_PUBLIC_ exposes
By default, every environment variable is server-only. It lives in the Node.js process and never reaches the browser. The client runs in a different environment and cannot see process.env.DB_PASS.
To send a value to the browser, you prefix it with NEXT_PUBLIC_:
NEXT_PUBLIC_ANALYTICS_ID=abcdefghijkNow understand the mechanic, because it is the whole point. At next build time, Next.js scans your code for literal references to process.env.NEXT_PUBLIC_ANALYTICS_ID and replaces each one with the actual string, inlining it into the JavaScript bundle shipped to the browser:
"use client"
import { setupAnalytics } from "@/lib/analytics"
// At build time this becomes setupAnalytics("abcdefghijk").
// The literal string ships to every browser.
setupAnalytics(process.env.NEXT_PUBLIC_ANALYTICS_ID)Two things follow directly from "inlined at build time":
- Only literal
process.env.NEXT_PUBLIC_FOOreferences are replaced. Dynamic lookups are not.process.env[varName]and destructuringconst env = process.env; env.NEXT_PUBLIC_FOOwill not be inlined and will beundefinedin the browser. - The value is frozen at build time. If you build one Docker image and promote it across staging and production, every
NEXT_PUBLIC_value carries the value from the build, not the deploy. To vary a public value per environment, build per environment or fetch it at runtime from your own API.
Read secrets on the server only
Server Components are the default in Next.js 16, and that default is your friend. A Server Component runs in Node, reads process.env, uses the secret, and sends only rendered output to the browser. The secret never leaves the server.
Read a secret during dynamic rendering by opting in with connection() so the value is evaluated at request time rather than baked in at build:
import { connection } from "next/server"
export default async function Page() {
// connection() opts this render into dynamic rendering,
// so the secret is read at runtime, not inlined at build.
await connection()
const db = await connectToDb({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS, // server-only, never shipped
})
const rows = await db.query("select name from products limit 5")
return (
<ul>
{rows.map((r) => (
<li key={r.name}>{r.name}</li>
))}
</ul>
)
}Route handlers run on the server too, so they read secrets the same way. Note that in Next.js 16 a GET handler is uncached by default, which is correct for anything that reads a secret and returns live data. Only add export const dynamic = "force-static" when the response is genuinely static.
export async function GET() {
// Server-only. process.env.DB_PASS is never inlined into client JS.
const db = await connectToDb({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
})
const products = await db.query("select id, name from products")
return Response.json({ products })
}Avoid
"use client"
// WRONG: prefixing a secret with NEXT_PUBLIC_ inlines it
// into the browser bundle. Anyone can read it in DevTools.
const stripe = new Stripe(process.env.NEXT_PUBLIC_STRIPE_SECRET_KEY!)
export function Checkout() {
// The secret key now ships to every visitor.
return <button onClick={() => stripe.charge()}>Pay</button>
}Prefer
"use server"
import Stripe from "stripe"
// Correct: no NEXT_PUBLIC_ prefix, read inside a server action.
// The key stays in the Node process and never reaches the browser.
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function charge(amount: number) {
const intent = await stripe.paymentIntents.create({
amount,
currency: "usd",
})
return { clientSecret: intent.client_secret }
}The client calls the server action and receives only the clientSecret it needs, never the API key itself.
Ignore .env.local in git and commit .env.example
A secret in your git history is a secret you have leaked, even after you delete the file in a later commit. The fix is to never let real values reach the repository.
create-next-app already adds .env* files to .gitignore. Confirm yours does, and make the intent explicit:
# local env files with real secrets, never commit
.env*.localThen commit a template that documents the required keys with empty or fake values. This is the file teammates copy to get started:
# Copy to .env.local and fill in real values. Do not commit .env.local.
DB_HOST=localhost
DB_USER=
DB_PASS=
NEXT_PUBLIC_ANALYTICS_ID=
STRIPE_SECRET_KEY=Setup for a new contributor is then one command:
cp .env.example .env.local.env.local or any file containing a real credential. Commit .env.example with the keys and blank values instead.Verify it before you ship
A leaked secret is easy to confirm. Run a production build and search the client bundle for a value you know should be server-only:
next build
grep -r "STRIPE_SECRET_KEY" .next/static && echo "LEAK" || echo "clean"If a secret string appears under .next/static, it shipped to the browser. That almost always means it got a NEXT_PUBLIC_ prefix it should not have, or it was read inside a "use client" component. Move the read to the server and rebuild.