DevOps & Deployment
Environment Variables in Next.js: The Production Guide
Last updated: April 14, 2026
TL;DR
Environment variables in Next.js are simple in concept and brutal in practice. The NEXT_PUBLIC_ prefix exposes variables to the browser bundle at build time — miss the prefix and your client code silently gets undefined. Forget that env vars are inlined at build time and your staging build ships with production keys. Skip validation and your app crashes at midnight because someone deleted a variable from the Vercel dashboard. I manage env vars across every project with a three-part system: Zod schemas that crash the build if anything is missing, strict .env file separation for local and CI, and Vercel's per-environment variable settings for preview and production. This guide is the complete walkthrough of that system.
How Next.js Env Vars Work
Next.js has its own environment variable system built on top of Node.js, and it works differently from what you might expect if you are coming from a plain Node or Express background.
At its core, Next.js loads environment variables from .env files and makes them available through process.env. But there is a critical distinction: not all environment variables are available everywhere. Next.js separates variables into two categories — server-only and public — and this separation is the source of most env-related bugs I have ever debugged.
When you define a variable like DATABASE_URL=postgres://... in your .env file, that variable is only available in server-side code. Server Components, Route Handlers, Server Actions, middleware, next.config.js — these can all read it. But if you try to access process.env.DATABASE_URL in a Client Component, you get undefined. No error. No warning. Just undefined.
This is intentional. Next.js is protecting you from leaking secrets to the browser. The variable never gets bundled into the JavaScript that ships to the client. This is good security, but it creates a class of bugs where things work perfectly in your server-rendered initial page load and then break the moment React hydrates on the client.
The mechanism is straightforward: during the build step, Next.js uses webpack's DefinePlugin (or the equivalent in Turbopack) to perform a literal string replacement. Every occurrence of process.env.NEXT_PUBLIC_SOMETHING in your client-side code gets replaced with the actual string value at build time. If the variable does not exist, it gets replaced with undefined. There is no runtime lookup — the values are baked into the JavaScript bundle.
// This works in Server Components, Route Handlers, Server Actions
const dbUrl = process.env.DATABASE_URL;
// This returns undefined in Client Components
const alsoDbUrl = process.env.DATABASE_URL; // undefined
// This works everywhere — including Client Components
const apiUrl = process.env.NEXT_PUBLIC_API_URL;Understanding this mechanism is the foundation for everything else in this guide.
Server vs Client — NEXT_PUBLIC_
The NEXT_PUBLIC_ prefix is the gate between server and client. Any environment variable that starts with NEXT_PUBLIC_ gets inlined into the client JavaScript bundle. Everything else stays on the server.
Here is how I think about the split:
Server-only (no prefix):
- Database connection strings
- API secret keys (Stripe secret, Supabase service role)
- JWT signing secrets
- SMTP credentials
- Webhook signing secrets
- Any third-party API key that should not be public
Client-accessible (NEXT_PUBLIC_ prefix):
- Public API endpoints (your own API base URL)
- Supabase anon key (designed to be public, protected by RLS)
- Stripe publishable key
- Analytics IDs (Google Analytics, Posthog)
- Feature flags for the UI
- App metadata (site URL, app name)
// .env.local
DATABASE_URL="postgresql://user:password@host:5432/db"
STRIPE_SECRET_KEY="sk_live_..."
SUPABASE_SERVICE_ROLE_KEY="eyJ..."
NEXT_PUBLIC_SUPABASE_URL="https://abc.supabase.co"
NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJ..."
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_live_..."
NEXT_PUBLIC_SITE_URL="https://iamuvin.com"A rule I follow without exception: if you are not absolutely certain a variable needs to be in the browser, do not add the `NEXT_PUBLIC_` prefix. Default to server-only. You can always access a server-only variable through a Route Handler or Server Action if your client code needs the data — you just cannot access it directly in a Client Component.
One subtle trap: destructuring process.env does not work for client-side variables.
// This BREAKS — the build cannot statically analyze destructuring
const { NEXT_PUBLIC_API_URL } = process.env;
// This WORKS — direct property access is statically analyzable
const apiUrl = process.env.NEXT_PUBLIC_API_URL;The build tool needs to see the full string process.env.NEXT_PUBLIC_API_URL as a single expression to perform the replacement. Destructuring breaks that pattern.
.env Files and Precedence
Next.js loads environment variables from multiple .env files, and the order matters. Here is the precedence from highest to lowest priority:
- `process.env` — Variables set in the shell or by your hosting provider (Vercel). These always win.
- `.env.$(NODE_ENV).local` — Environment-specific local overrides (
.env.development.local,.env.production.local). Gitignored. - `.env.local` — Local overrides for all environments. Gitignored. Not loaded in test environments.
- `.env.$(NODE_ENV)` — Environment-specific defaults (
.env.development,.env.production). Committed to git. - `.env` — Default fallback for all environments. Committed to git.
Higher-priority files override lower-priority ones. A variable defined in .env.local overrides the same variable in .env.
Here is what I commit to git and what I do not:
# Committed to git — safe defaults, no secrets
.env # Shared defaults for all environments
.env.development # Dev-specific defaults (localhost URLs)
.env.production # Prod-specific non-secret values
.env.test # Test environment config
# Gitignored — contains real secrets
.env.local # Local overrides with real API keys
.env.development.local # Dev overrides
.env.production.local # Never use this — production secrets go in VercelMy .gitignore always includes:
.env*.localThe files I commit to git contain only non-secret values: public API endpoints, feature flags, application metadata. They serve as documentation of which variables the application expects. Real secrets live in .env.local for local development and in Vercel's environment variable settings for deployment.
Vercel Environment Settings
Vercel gives you three deployment contexts for environment variables: Production, Preview, and Development. This maps cleanly to how I think about environments.
Production variables apply to your main branch deployment. This is where your live Stripe keys, production database URL, and real API credentials go.
Preview variables apply to every pull request and branch deploy. I use staging API keys, a separate database, and test payment credentials here. This means I can safely test with real infrastructure without touching production data.
Development variables are downloaded when you run vercel env pull, which writes them to .env.local. This is how I share dev credentials with team members without committing secrets to git.
Setting this up in the Vercel dashboard:
- Navigate to your project settings and open the Environment Variables tab.
- Add each variable and select which environments it applies to.
- For sensitive values, check the Sensitive toggle — this prevents the value from being readable through the Vercel API or dashboard after creation.
# Pull development variables to .env.local
vercel env pull .env.local
# Pull preview variables (for testing preview-specific config locally)
vercel env pull .env.local --environment=previewA pattern I always follow: my production database has a different hostname from my preview database. The DATABASE_URL variable is set separately for Production and Preview in Vercel. This means pull request deploys never risk corrupting production data.
For variables that are the same across all environments (like NEXT_PUBLIC_SITE_NAME), I set them once with all three checkboxes selected. For anything environment-specific (database URLs, API keys, webhook secrets), I set them separately.
Type-Safe Env with Zod Validation
This is the single most impactful pattern in this entire guide. I validate every environment variable at build time using Zod. If a variable is missing or malformed, the build crashes immediately with a clear error message instead of silently deploying a broken application.
Here is the exact setup I use on every project:
// src/env.ts
import { z } from "zod";
const serverSchema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
STRIPE_WEBHOOK_SECRET: z.string().startsWith("whsec_"),
SUPABASE_SERVICE_ROLE_KEY: z.string().min(1),
RESEND_API_KEY: z.string().startsWith("re_"),
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
});
const clientSchema = z.object({
NEXT_PUBLIC_SUPABASE_URL: z.string().url(),
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string().startsWith("pk_"),
NEXT_PUBLIC_SITE_URL: z.string().url(),
});
const serverEnv = serverSchema.safeParse(process.env);
const clientEnv = clientSchema.safeParse({
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL,
});
if (!serverEnv.success) {
console.error(
"Invalid server environment variables:",
serverEnv.error.flatten().fieldErrors
);
throw new Error("Invalid server environment variables");
}
if (!clientEnv.success) {
console.error(
"Invalid client environment variables:",
clientEnv.error.flatten().fieldErrors
);
throw new Error("Invalid client environment variables");
}
export const env = {
...serverEnv.data,
...clientEnv.data,
};Then I import env instead of accessing process.env directly throughout the application:
// Instead of this
const dbUrl = process.env.DATABASE_URL!;
// I do this
import { env } from "@/env";
const dbUrl = env.DATABASE_URL;This gives me autocomplete, type safety, and build-time validation. If someone removes STRIPE_SECRET_KEY from the Vercel dashboard, the next deploy fails at build time with a clear message telling them exactly which variable is missing. No more debugging a live 500 error at midnight because an env var disappeared.
Notice that I explicitly list the NEXT_PUBLIC_ variables when parsing the client schema rather than passing process.env directly. This is because the client-side bundle can only access NEXT_PUBLIC_ variables through direct process.env.NEXT_PUBLIC_* access — passing the full process.env object would not include them in the client bundle.
Runtime vs Build-Time
This distinction is where most production bugs originate. In Next.js, environment variables are resolved at build time, not at runtime. This matters enormously.
When you run next build, Next.js reads your environment variables and inlines them into the output. The resulting .next directory contains JavaScript files with your variable values hardcoded as literal strings. If you build your app with NEXT_PUBLIC_API_URL=https://staging.api.com and then deploy that same build artifact to production, your production deployment will still point at the staging API.
This is why Vercel rebuilds your application for each environment. When you push to a preview branch, Vercel builds with preview environment variables. When you merge to main, Vercel builds again with production environment variables. Two separate builds, two separate sets of inlined values.
For Docker deployments or any scenario where you want to build once and deploy to multiple environments, you need runtime environment variables. Next.js supports this through next.config.js:
// next.config.ts
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
env: {
// These are inlined at build time — same as NEXT_PUBLIC_
CUSTOM_VAR: process.env.CUSTOM_VAR,
},
// For true runtime variables on the server side,
// use serverRuntimeConfig or publicRuntimeConfig (legacy)
// But the modern approach is to just read process.env in server code
};
export default nextConfig;For server-side code (Route Handlers, Server Components, Server Actions), process.env is always evaluated at runtime. The build-time inlining only applies to client-side code. This means your DATABASE_URL is read at runtime when the server handles a request — which is why Docker deployments work fine for server-only variables without any special configuration.
The build-time problem only affects NEXT_PUBLIC_ variables in client components. If you need truly runtime public variables, consider fetching them from a server endpoint instead of inlining them.
Secrets Management
I treat secrets management as a layered system. No single tool handles everything.
Layer 1 — Local development. Secrets live in .env.local, which is gitignored. I share these with team members through Vercel's vercel env pull command or through a password manager. Never through Slack. Never through email.
Layer 2 — CI/CD. GitHub Actions secrets or Vercel environment variables. For GitHub Actions, I add secrets through the repository settings and reference them in workflow files:
# .github/workflows/ci.yml
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}Layer 3 — Production. Vercel environment variables with the Sensitive flag enabled. Once a sensitive variable is set, its value cannot be read through the Vercel dashboard or API — only overwritten. This limits the blast radius if someone gains access to the Vercel account.
Layer 4 — Rotation. Every API key and secret should be rotatable without a code deploy. Since server-side variables are read at runtime, rotating a database password means updating it in Vercel and redeploying (which triggers a fresh build that picks up the new value). For zero-downtime rotation, use two variables — DATABASE_URL and DATABASE_URL_PREVIOUS — and update your connection logic to fall back to the previous URL during the rotation window.
A non-negotiable rule: never log environment variables. Not even partially. Not even the first four characters. I have seen teams leak API keys through error logging services because they logged the full process.env object during debugging and forgot to remove it.
Common Mistakes That Leak Secrets
I have seen every one of these in production codebases. Some I caught in code review. Some I caused myself.
Mistake 1: Committing `.env.local` to git. This is the classic. Someone adds their .env.local before adding it to .gitignore, pushes, and now their Stripe secret key is in the git history forever. Even if you delete the file in a later commit, the secret is in the history. The fix: rotate the key immediately, then use git filter-branch or BFG Repo Cleaner to purge the file from history.
# Check if any .env files are tracked
git ls-files | grep '\.env'Mistake 2: Using `NEXT_PUBLIC_` on a secret key. If you name a variable NEXT_PUBLIC_STRIPE_SECRET_KEY, that secret key gets inlined into your client JavaScript bundle. Anyone can open DevTools, look at the source, and extract it. The prefix makes it public — that is its entire purpose.
Mistake 3: Logging `process.env` in server code.
// NEVER do this
console.log("Environment:", process.env);
// If you must debug a specific variable, check its existence, not its value
console.log("DATABASE_URL exists:", !!process.env.DATABASE_URL);Mistake 4: Exposing env vars through an API route.
// This is a security vulnerability
export function GET() {
return Response.json({ env: process.env });
}I have seen this in "debug" endpoints that were supposed to be temporary. Ship it once, forget to remove it, and every secret is publicly accessible.
Mistake 5: Hardcoding secrets as fallback values.
// This defeats the entire purpose of environment variables
const apiKey = process.env.API_KEY || "sk_live_abc123realkey";If the env var is missing, you want the app to crash — not silently use a hardcoded key that might be a production credential committed to source control.
Mistake 6: Different env var names across environments. Your local .env.local has DB_URL but Vercel has DATABASE_URL. Your code checks process.env.DATABASE_URL, which works in production but is undefined locally. Use the Zod validation pattern from earlier to catch this.
Environment-Specific Config
Beyond individual variables, I often need configuration objects that change shape across environments. I handle this with a typed configuration module that builds on the Zod-validated env:
// src/config.ts
import { env } from "@/env";
export const config = {
app: {
name: "IAMUVIN",
url: env.NEXT_PUBLIC_SITE_URL,
isProduction: env.NODE_ENV === "production",
isDevelopment: env.NODE_ENV === "development",
},
stripe: {
secretKey: env.STRIPE_SECRET_KEY,
publishableKey: env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
},
supabase: {
url: env.NEXT_PUBLIC_SUPABASE_URL,
anonKey: env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY,
},
email: {
apiKey: env.RESEND_API_KEY,
from: env.NODE_ENV === "production"
? "Uvin <hello@iamuvin.com>"
: "Uvin <test@iamuvin.com>",
},
} as const;This gives me a single place to look up how any external service is configured. When debugging a Stripe integration, I do not need to search the codebase for process.env.STRIPE_* — I check config.stripe and see every Stripe-related value in one place.
For behavior that changes between environments:
// src/config.ts continued
export const features = {
enableAnalytics: config.app.isProduction,
enableEmailSending: config.app.isProduction,
showDebugTools: config.app.isDevelopment,
logLevel: config.app.isProduction ? "error" : "debug",
} as const;This pattern eliminates scattered process.env.NODE_ENV === 'production' checks throughout the codebase. Every environment-specific behavior is documented in one file.
My .env Setup
Here is the exact file structure I use on every Next.js project. I am sharing the template — obviously with placeholder values.
# .env — committed to git, shared defaults
# Every variable here is either non-secret or a placeholder
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
NEXT_PUBLIC_SITE_NAME="IAMUVIN"# .env.development — committed to git, development defaults
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
NEXT_PUBLIC_SUPABASE_URL="http://127.0.0.1:54321"
NEXT_PUBLIC_SUPABASE_ANON_KEY="eyJ-your-local-supabase-anon-key"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."# .env.local — gitignored, real secrets for local development
DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
SUPABASE_SERVICE_ROLE_KEY="eyJ-your-local-service-role-key"
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
RESEND_API_KEY="re_..."# .env.test — committed to git, test environment
NEXT_PUBLIC_SITE_URL="http://localhost:3000"
DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/test_db"
NEXT_PUBLIC_SUPABASE_URL="http://127.0.0.1:54321"
NEXT_PUBLIC_SUPABASE_ANON_KEY="test-anon-key"
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_test_..."# .env.example — committed to git, documentation for new developers
# Copy this file to .env.local and fill in the values
# Get credentials from the team password manager or run: vercel env pull
DATABASE_URL="postgresql://user:password@host:5432/dbname"
SUPABASE_SERVICE_ROLE_KEY=""
STRIPE_SECRET_KEY="sk_test_..."
STRIPE_WEBHOOK_SECRET="whsec_..."
RESEND_API_KEY="re_..."
NEXT_PUBLIC_SUPABASE_URL="https://your-project.supabase.co"
NEXT_PUBLIC_SUPABASE_ANON_KEY=""
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_..."
NEXT_PUBLIC_SITE_URL="http://localhost:3000"And the .gitignore entries that protect this setup:
# Environment files with secrets
.env*.localOn Vercel, I set all production and preview variables through the dashboard. The committed .env files only contain development defaults and documentation. Nothing secret ever touches version control.
When onboarding a new developer, the workflow is:
- Clone the repo.
- Run
vercel env pull .env.local(or copy.env.exampleand fill in values from the password manager). - Run
npm install && npm run dev.
The Zod validation catches any missing variables immediately, telling the developer exactly what they need to add.
Key Takeaways
- `NEXT_PUBLIC_` prefix = public. Anything with this prefix is embedded in the client bundle. Never use it for secrets.
- Env vars are build-time for client code.
NEXT_PUBLIC_variables are inlined duringnext build. Different builds for different environments. - Validate with Zod. Crash at build time if a variable is missing. A failed deploy is infinitely better than a broken production app.
- Never commit secrets to git. Use
.env.local(gitignored) for local development. Use Vercel environment settings for deployments. - Never destructure `process.env`. Access variables with the full
process.env.NEXT_PUBLIC_*path for client-side code. - Separate preview from production. Different database, different API keys, different webhook secrets. One variable in the wrong environment can corrupt production data.
- Create a typed config module. Import
envfrom a validated schema. Get autocomplete and catch typos at build time. - Never log `process.env`. Not in development. Not in staging. Definitely not in production. Check existence with
!!, not value.
Environment variables are the boring part of web development until they are not. One misconfigured variable can leak your Stripe secret key to the internet or silently connect your staging app to the production database. The patterns in this guide have saved me from both of those scenarios.
If you are building a Next.js application and need help setting up a production-grade deployment pipeline — environment variables, CI/CD, monitoring, the works — check out my services. I help teams ship with confidence.
*Built and shipped by Uvin Vindula↗ — Web3 and AI engineer building production systems from Sri Lanka and the UK. Follow the journey at @iamuvin↗.*
Working on a Web3 or AI project?

Uvin Vindula
Web3 and AI engineer based in Sri Lanka and the UK. Author of The Rise of Bitcoin. Director of Blockchain and Software Solutions at Terra Labz. Founder of uvin.lk — Sri Lanka's Bitcoin education platform with 10,000+ learners.