// LOADING
// LOADING
// LOADING_ARTICLE
Environment variables are one of those topics that seem trivial until you accidentally ship a private API key to a browser bundle. Next.js has a well-designed env var system with a deliberate public/server split, but it's easy to misuse. This post covers the rules, the common failure modes, and a practical approach to secrets management across local, staging, and production.
If you're also thinking about protecting your API endpoints beyond secrets, rate limiting your Next.js APIs is the natural companion to this guide.
Next.js exposes environment variables to server-side code by default. To make a variable available in client-side (browser) code, you must prefix it with NEXT_PUBLIC_.
bash# .env.local NEXT_PUBLIC_POSTHOG_KEY=phc_abc123 # safe to expose DATABASE_URL=mongodb+srv://user:pass@... # server-only — never prefix with NEXT_PUBLIC_ JWT_SECRET=super-secret-value # server-only AWS_SECRET_ACCESS_KEY=wJalrXUtn... # server-only
Anything prefixed NEXT_PUBLIC_ gets inlined into the JavaScript bundle at build time. It is not dynamic — it is baked into the HTML that ships to users. Treat it as public information.
Variables without the prefix are only available in:
app/api/)getServerSideProps / getStaticProps (Pages Router)next.config.ts itselfThe most common accidental leak pattern looks like this:
typescript// components/SomeClientComponent.tsx ← runs in the browser 'use client'; export function ApiCaller() { // BAD: process.env.DATABASE_URL is undefined in the browser, // but if you accidentally wrote NEXT_PUBLIC_DATABASE_URL, // it would be fully visible in the page source. const url = process.env.NEXT_PUBLIC_DATABASE_URL; // ... }
Another vector is importing a server module into a client component. If a file at the top of its import tree reads a secret env var and you import it from a Client Component, Next.js will warn in dev but the variable will simply be undefined in the bundle — it won't leak. However, if someone switches it to NEXT_PUBLIC_ to silence a runtime error, the leak becomes real.
Use the server-only package to create a hard build-time error when a server module is imported from client code:
bashnpm install server-only
typescript// lib/db.ts import 'server-only'; import { MongoClient } from 'mongodb'; const client = new MongoClient(process.env.DATABASE_URL!); export default client;
Now any accidental client import of lib/db.ts fails at build time, not silently at runtime.
Next.js loads .env files in this order (later files win for the same key):
.env — committed defaults, no secrets.env.local — local overrides, gitignored.env.development / .env.production / .env.test — environment-specific, can be committed if they contain no secrets.env.development.local / .env.production.local — gitignored local overridesA good .gitignore entry:
.env*.local
.env.production
Commit a .env.example that lists every required variable with a fake or blank value. New teammates clone the repo, copy .env.example to .env.local, and fill in real values. This also serves as documentation.
Missing env vars cause cryptic runtime errors deep inside your API handlers. Validate at startup instead:
typescript// lib/env.ts import { z } from 'zod'; const envSchema = z.object({ DATABASE_URL: z.string().url(), JWT_SECRET: z.string().min(32), AWS_REGION: z.string(), NEXT_PUBLIC_APP_URL: z.string().url(), }); export const env = envSchema.parse(process.env);
Import env from this module anywhere you need a variable. If any value is missing or malformed, the process crashes immediately with a clear error message instead of a 500 deep inside request handling. This also gives you full TypeScript completion for env var names.
For deeper background on runtime validation patterns, see the Runtime Validation in TypeScript with Zod post.
For Vercel, Netlify, Render, or Railway, always inject secrets through the platform's environment variable UI — never commit them. The platform encrypts values at rest and injects them at build/runtime.
For self-hosted or multi-service setups, use a dedicated secret store:
A typical pattern: the container's IAM role grants read access to specific secrets, and a small bootstrap script fetches them and exports them as env vars before the Node process starts.
GitHub Actions, CircleCI, and similar systems have their own secrets stores. Inject them as env vars in the workflow, never print them in logs, and use environment-specific secret scopes (staging secrets vs production secrets).
yaml# .github/workflows/deploy.yml jobs: deploy: env: DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }} JWT_SECRET: ${{ secrets.JWT_SECRET }}
Combining this with a solid CI/CD pipeline for Next.js reduces the chance of a misconfigured deploy reaching production.
NEXT_PUBLIC_server-only imports to modules that read sensitive variables.env.example, gitignore .env*.local and .env.productionThe NEXT_PUBLIC_ prefix rule is simple, but the failure modes compound quickly in larger codebases with many contributors. Automated validation at startup and the server-only guard are the two highest-leverage additions you can make today. Pair those with a committed .env.example and a proper CI secrets store, and accidental leaks become structurally difficult rather than just a matter of discipline.
See the projects page for examples of these patterns in real Next.js applications, or reach out via contact if you have questions.