· 2 min read · Next.js, Security, TypeScript, DevOps
Environment variables and secrets in Next.js without leaking them
How Next.js decides which environment variables reach the browser, why NEXT_PUBLIC_ is a one-way door, and how to validate and keep API keys on the server.
Almost every app has secrets: a database URL, an email provider's API key, a payment secret. In Next.js, where server and client code live side by side in the same project, it's easy to send one of them to the browser without noticing. Here's how Next.js handles environment variables and the habits that keep secrets on the server.
The one rule: NEXT_PUBLIC_ means public
At build time, Next.js replaces every process.env.NEXT_PUBLIC_* in client code with its actual value. Anyone can read it by opening the JavaScript files in their browser. Variables without the prefix are only available on the server.
# .env.local
DATABASE_URL=postgres://... # server only
BREVO_API_KEY=xkeysib-... # server only
NEXT_PUBLIC_SITE_URL=https://... # safe to be publicOnly put values in NEXT_PUBLIC_ variables that you'd be happy to print on your homepage: a site URL, an analytics ID, a Supabase anon key protected by row-level security. Never an API secret. If a secret was ever public, even briefly, rotate it. Removing it from the code doesn't remove it from old builds or caches.
Public values are frozen at build time
Because NEXT_PUBLIC_ values are written into the bundle during next build, changing them in your hosting dashboard does nothing until you rebuild. Server-only variables are read when the code runs. If a public value "won't update" after you change it, trigger a new deploy.
Make server-only code impossible to import from the client
The server-only package turns an accidental import into a build error instead of a leak:
// lib/email.ts
import "server-only";
export async function sendEmail(to: string, subject: string, html: string) {
await fetch("https://api.brevo.com/v3/smtp/email", {
method: "POST",
headers: { "api-key": process.env.BREVO_API_KEY!, "content-type": "application/json" },
body: JSON.stringify({ to: [{ email: to }], subject, htmlContent: html }),
});
}If a component marked "use client" ever imports this file, the build fails with a clear message. I add it to every file that touches a secret.
Validate variables when the app starts
A missing variable often doesn't fail until someone submits a form in production. Checking them all in one place, with a schema, catches it at build time instead:
// lib/env.ts
import "server-only";
import { z } from "zod";
export const env = z
.object({
DATABASE_URL: z.string().url(),
BREVO_API_KEY: z.string().min(1),
CONTACT_TO_EMAIL: z.string().email(),
})
.parse(process.env);Everywhere else imports env.BREVO_API_KEY instead of reading process.env directly. You get autocomplete, correct types, and one file that documents what the app needs.
Keep .env files out of Git
- Commit a
.env.examplewith every variable name and a placeholder value, so the next developer knows what to set. - Keep
.env.localand any file with real values in.gitignore. - Store production values in your host's environment settings, not in the repository.
- If a secret was ever committed, rotate it. Deleting the file leaves it in the Git history.
Checklist
- Only public values get the
NEXT_PUBLIC_prefix - Rebuild after changing a public value
- Add
import "server-only"to files that use secrets - Validate every variable with a schema in one place
- Commit
.env.example, never.env.local - Rotate any secret that was ever exposed
Want a second pair of eyes on how your app handles secrets? Get in touch.