How to Use Environment Variables in Next.js (Without Leaking Them to the Browser)
Learn how to use .env files in Next.js correctly. Understand NEXT_PUBLIC_, avoid common mistakes, and set variables in Vercel and Cloudflare.
On this page
Every Next.js developer hits this wall at some point. You add a .env.local file, reference process.env.MY_API_KEY in your code, and everything works fine on your machine. Then you deploy. Either the variable is missing in production, or worse, it shows up in your browser's network tab for anyone to see.
This guide explains how environment variables actually work in Next.js, what the NEXT_PUBLIC_ prefix does, how to validate them at build time, and how to configure things correctly for Vercel, Cloudflare Workers, Docker, and any CI/CD pipeline.
How Next.js Environment Variables Work
Environment variables are key-value pairs your app reads at runtime or build time. They let you store secrets outside source code, run the same codebase differently per environment, and prevent credentials from being hardcoded into files that get committed.
Next.js reads four .env files in a specific order. When the same variable exists in multiple files, .env.local always wins:
.env # loaded in all environments
.env.local # loaded in all environments, git-ignored (secrets live here)
.env.development # loaded only during next dev
.env.production # loaded only during next build and next startThe loading order matters because later files override earlier ones. If you define API_URL=http://localhost:3000 in .env and API_URL=https://api.prod.com in .env.production, the production value is used during next build and next start, while the localhost value applies during next dev.
You can also use .env.development.local and .env.production.local for machine-specific overrides in each environment. These local variants are always git-ignored and take the highest priority within their environment.
| File | Loaded when | Committed to Git? | Priority |
|---|---|---|---|
| .env | All environments | Yes | Lowest |
| .env.development | next dev only | Yes | Medium |
| .env.production | next build / next start | Yes | Medium |
| .env.local | All environments | No (git-ignored) | High |
| .env.development.local | next dev only | No (git-ignored) | Highest (in dev) |
| .env.production.local | next build / next start | No (git-ignored) | Highest (in prod) |
The NEXT_PUBLIC_ Prefix: Handle with Care
Next.js runs in two contexts, and environment variables behave differently in each. By default all variables are server-only. The NEXT_PUBLIC_ prefix explicitly opts a variable into the browser bundle.
At build time, Next.js scans your code for any reference to process.env.NEXT_PUBLIC_* and replaces it with the literal string value. This means the value is baked into the JavaScript output. There is no runtime lookup, no hidden API call. The raw value sits in your JS bundle for anyone to read.
| Server-only (safe for secrets) | NEXT_PUBLIC_ (visible in browser) | |
|---|---|---|
| Available in | Server Components, API routes, server actions | Client Components, browser, anywhere |
| In DevTools? | Never | Yes (fully visible) |
| Use for | DATABASE_URL, API_SECRET_KEY, tokens | Analytics keys, public API URLs, site URLs |
| Strips from bundle? | Yes: stripped at build time | No: inlined into JS bundle |
| Can change without rebuild? | Yes (runtime env on server) | No (baked in at build time) |
# Safe to expose: use NEXT_PUBLIC_
NEXT_PUBLIC_POSTHOG_KEY=phc_xxx
NEXT_PUBLIC_API_URL=https://api.example.com
# Must stay server-only, no NEXT_PUBLIC_ prefix
DATABASE_URL=postgresql://localhost:5432/mydb
API_SECRET_KEY=your-secret-hereBuild-Time vs Runtime Variables
One of the most confusing aspects of Next.js environment variables is the difference between build-time and runtime resolution. Understanding this distinction prevents a whole class of deployment bugs.
NEXT_PUBLIC_ variables are always resolved at build time. Next.js literally replaces process.env.NEXT_PUBLIC_API_URL with the string "https://api.example.com" in the compiled output. If you change the variable in your hosting dashboard and do not rebuild, the old value persists in the bundle.
Server-only variables (without the NEXT_PUBLIC_ prefix) behave differently depending on your rendering strategy. In static pages generated at build time with generateStaticParams, the values are captured during next build. In API routes or server actions that run on every request, the variables are read from the runtime environment.
// This value is baked into the HTML at build time
// Changing the env var later has no effect without a rebuild
export default function HomePage() {
return <p>Version: {process.env.NEXT_PUBLIC_APP_VERSION}</p>;
}
// This value is read fresh on every request
// Changing the env var takes effect immediately
export async function GET() {
const secret = process.env.API_SECRET_KEY;
return Response.json({ status: "ok" });
}Type-Safe Environment Variables with Validation
process.env values are always string | undefined in TypeScript. That means every access requires a null check or a type assertion, and you will not discover a missing variable until the code actually runs. For production apps, validating environment variables at build time prevents runtime crashes.
The most reliable approach is a small validation module that runs at startup. If any required variable is missing, the build fails immediately with a clear error message instead of silently producing undefined values.
// Validate and export typed environment variables
// Import this module wherever you need env vars
function requireEnv(key: string): string {
const value = process.env[key];
if (!value) {
throw new Error(
`Missing required environment variable: ${key}. ` +
`Check your .env.local file or hosting dashboard.`
);
}
return value;
}
// Server-only variables (never import this file in client components)
export const env = {
DATABASE_URL: requireEnv("DATABASE_URL"),
API_SECRET_KEY: requireEnv("API_SECRET_KEY"),
TURSO_AUTH_TOKEN: requireEnv("TURSO_AUTH_TOKEN"),
} as const;
// Public variables (safe to use anywhere)
export const publicEnv = {
SITE_URL: requireEnv("NEXT_PUBLIC_SITE_URL"),
POSTHOG_KEY: requireEnv("NEXT_PUBLIC_POSTHOG_KEY"),
} as const;With this pattern, you get autocompletion from your IDE, type safety without assertions, and an immediate build failure if someone forgets to set a variable. Every team member sees a clear error message pointing to the exact missing key.
import { z } from "zod";
const envSchema = z.object({
DATABASE_URL: z.string().url(),
API_SECRET_KEY: z.string().min(16),
NODE_ENV: z.enum(["development", "production", "test"]),
});
// Throws a detailed error if validation fails
export const env = envSchema.parse(process.env);Step-by-Step Setup
- 1
Create .env.local and add your variables
Create
.env.localat the project root (same level aspackage.json). How you access a variable depends on the prefix:bash — .env.local# Server-only (no prefix) DATABASE_URL=postgresql://localhost:5432/mydb API_SECRET_KEY=your-secret-here # Available everywhere (NEXT_PUBLIC_) NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_POSTHOG_KEY=phc_xxxtypescript — Accessing variables in your code// In a Server Component or API route (safe: server only) const dbUrl = process.env.DATABASE_URL; // In a Client Component (only works with NEXT_PUBLIC_) const siteUrl = process.env.NEXT_PUBLIC_SITE_URL; - 2
Commit a .env.example for your team
.env.localis git-ignored, so anyone cloning your repo won't know which variables are required. Commit a.env.examplewith blank values:bash — .env.example (commit this to Git)DATABASE_URL= API_SECRET_KEY= TURSO_DATABASE_URL= TURSO_AUTH_TOKEN= NEXT_PUBLIC_SITE_URL=http://localhost:3000 NEXT_PUBLIC_POSTHOG_KEY= - 3
Set variables in Vercel and Cloudflare
Neither platform reads
.envfiles from your repository. Variables must be added through the dashboard:- Vercel: Project >
Settings > Environment Variables> add each variable > select environments (Production, Preview, Development) > save > redeploy - Cloudflare: Project >
Settings > Environment Variables> add variables under Production or Preview >Save and Deploy - Cloudflare Workers runs at the edge, not in Node.js: test your variable access after the first deploy to confirm everything resolves correctly.
- Vercel: Project >
- 4
Add validation to catch missing variables early
Create a validation module (like the
src/lib/env.tsexample above) and import it in your app's entry points. If any required variable is missing, the build will fail with a descriptive error rather than producing subtle runtime bugs.Import the validation module in your root layout or a top-level server component so it runs during the build step:
typescript — src/app/layout.tsximport "@/lib/env"; // Validates all env vars at build time export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body>{children}</body> </html> ); }
Environment Variables in Docker
Docker adds another layer to the equation. Variables can be injected at build time (when the image is created) or at runtime (when the container starts). Mixing these up is a frequent source of deployment bugs.
For NEXT_PUBLIC_ variables, the values must be available during docker build because Next.js inlines them at compile time. Pass them as build arguments. For server-only variables, inject them at runtime using -e flags or an env file so you can change them without rebuilding the image.
FROM node:20-alpine AS builder
# Build-time variables (NEXT_PUBLIC_ values get baked into the bundle)
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_POSTHOG_KEY
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package*.json ./
# Server-only vars are injected at runtime, not build time
# docker run -e DATABASE_URL=... -e API_SECRET_KEY=... my-app
CMD ["npm", "start"]# Pass NEXT_PUBLIC_ values at build time
docker build \
--build-arg NEXT_PUBLIC_SITE_URL=https://example.com \
--build-arg NEXT_PUBLIC_POSTHOG_KEY=phc_xxx \
-t my-next-app .
# Pass server-only secrets at runtime
docker run \
-e DATABASE_URL=postgresql://db:5432/prod \
-e API_SECRET_KEY=real-secret-here \
-p 3000:3000 \
my-next-appEnvironment Variables in Tests
Tests need their own set of environment variables. You do not want your test suite hitting a production database or a live API. Next.js does not load .env files automatically during test runs, so you need to handle this explicitly.
If you are using Jest or Vitest, create a .env.test file and load it in your test setup. Alternatively, define test values directly in your test configuration.
import { defineConfig } from "vitest/config";
import { loadEnv } from "vite";
export default defineConfig({
test: {
env: {
DATABASE_URL: "postgresql://localhost:5432/test_db",
API_SECRET_KEY: "test-secret-key-not-real",
NEXT_PUBLIC_SITE_URL: "http://localhost:3000",
},
},
});# Loaded manually in test setup (not auto-loaded by Next.js)
DATABASE_URL=postgresql://localhost:5432/test_db
API_SECRET_KEY=test-secret-key-not-real
NEXT_PUBLIC_SITE_URL=http://localhost:3000Common Mistakes and How to Fix Them
After years of Next.js projects, certain environment variable mistakes come up repeatedly. Here are the most common pitfalls and their fixes.
Destructuring process.env
Next.js replaces process.env.VARIABLE_NAME with the actual value using a simple string substitution at build time. If you destructure process.env, the replacement cannot find the full expression and the variable resolves to undefined.
// This will NOT work: destructuring breaks the string replacement
const { NEXT_PUBLIC_API_URL } = process.env;
console.log(NEXT_PUBLIC_API_URL); // undefined
// This works: direct property access
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
console.log(apiUrl); // "https://api.example.com"
// This also fails: dynamic key access
const key = "NEXT_PUBLIC_API_URL";
console.log(process.env[key]); // undefined in client componentsQuoting Values Incorrectly
Next.js .env files follow the dotenv convention. Quotes are optional for simple values, but they matter for values containing spaces or special characters. A common mistake is adding quotes around a URL, which includes the literal quote characters in the value.
# Correct: no quotes needed for simple values
DATABASE_URL=postgresql://localhost:5432/mydb
# Correct: use double quotes for values with spaces
APP_NAME="My Next App"
# Wrong: the quotes become part of the value
DATABASE_URL="postgresql://localhost:5432/mydb"
# process.env.DATABASE_URL === '"postgresql://localhost:5432/mydb"'
# This breaks database connections!
# Correct: single quotes prevent variable expansion
GREETING='Hello $USER'
# process.env.GREETING === "Hello $USER" (literal dollar sign)Forgetting to Restart the Dev Server
Unlike hot module replacement for code changes, .env files are only read when the dev server starts. If you add or change a variable and do not restart next dev, the old value (or undefined) persists. This catches developers off guard because code changes update instantly, but environment changes do not.
Pre-Deploy Security Checklist
Run through this before every deployment. These are also the most common sources of environment variable bugs:
.env.localis listed in.gitignore- No secret keys use the
NEXT_PUBLIC_prefix .env.exampleis committed to Git with blank values- Production variables are set in the platform dashboard, not committed to any file
- Every API key has the minimum permissions it needs
- Added new variables to CI/CD secrets (GitHub Actions cannot read
.env.local) - Triggered a new deployment after changing dashboard variables
- Environment validation module runs during build and fails fast on missing vars
- Docker images do not contain secrets in build layers (
docker historyshows no sensitive values)
Frequently Asked Questions
Why is process.env.MY_VAR undefined in my client component?
Environment variables are server-side only by default. Next.js strips them from the client bundle at build time to prevent accidental secret leakage.
# Won't work in a client component
MY_API_KEY=secret123
# Accessible in the browser
NEXT_PUBLIC_SITE_URL=https://example.comRename the variable with the NEXT_PUBLIC_ prefix if the value is safe to expose publicly. Then restart the dev server and rebuild. If the value is a secret, do not add the prefix. Instead, create a server API route that reads the secret and returns only the data the client needs.
What is the difference between .env and .env.local?
| .env | .env.local | |
|---|---|---|
| Committed to Git? | Yes | No (git-ignored) |
| Shared with team? | Yes | No (machine-specific) |
| Use for | Non-secret shared defaults | All secrets and personal values |
| Override priority | Lower | Higher (always wins) |
Use .env for non-sensitive defaults that the whole team shares (like NEXT_PUBLIC_SITE_URL=http://localhost:3000). Put all secrets and machine-specific values in .env.local, which Git ignores by default.
Do I need to redeploy after changing environment variables in Vercel or Cloudflare?
Yes. Both platforms inject variables at build time. A change in the dashboard takes effect only on the next deployment: push a commit or click the manual redeploy button to apply it.
For NEXT_PUBLIC_ variables this is especially important because the values are inlined into the JavaScript bundle. Even server-only variables on Vercel require a redeploy because Vercel creates a new serverless function bundle on each build.
Can people see my NEXT_PUBLIC_ variables in the browser?
Yes. NEXT_PUBLIC_ variables are inlined into the JavaScript bundle at build time and are fully visible in DevTools. Anyone can open the Sources or Network tab and search for the value. Only use this prefix for values you are comfortable making completely public, such as analytics IDs, public API base URLs, or feature flags.
How do I use environment variables in GitHub Actions?
Add secrets in the repository under Settings > Secrets and Variables > Actions. Reference them in your workflow file:
jobs:
build:
steps:
- name: Build
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_SECRET_KEY: ${{ secrets.API_SECRET_KEY }}
NEXT_PUBLIC_SITE_URL: ${{ vars.NEXT_PUBLIC_SITE_URL }}
run: npm run build.env.local is never available in CI: every required variable must be added explicitly to repository secrets. For non-secret public values, use GitHub's vars context instead of secrets so the values are visible in workflow logs for easier debugging.
Why does destructuring process.env break my variables?
Next.js uses a compile-time string replacement, not a real runtime object lookup. It scans your code for the literal text process.env.VARIABLE_NAME and swaps it with the value. When you destructure (const { MY_VAR } = process.env), the compiler cannot match the pattern and the replacement does not happen.
// Broken: destructuring
const { NEXT_PUBLIC_API_URL } = process.env;
// Working: direct access
const apiUrl = process.env.NEXT_PUBLIC_API_URL;This limitation applies to NEXT_PUBLIC_ variables in client components. On the server side, process.env is a real object and destructuring works normally. However, using direct access everywhere is a safer habit.
How do I use different values for development, staging, and production?
For development vs production, use .env.development and .env.production. Next.js loads the correct file based on the command (next dev vs next build). For staging, the approach depends on your hosting platform.
# .env.development (local dev)
NEXT_PUBLIC_API_URL=http://localhost:4000/api
# .env.production (production build)
NEXT_PUBLIC_API_URL=https://api.example.comFor staging environments on Vercel, set variables scoped to the Preview environment in the dashboard. On Cloudflare, use the Preview environment settings. This keeps staging values separate from production without adding more .env files to your repository.
Do environment variables work in Next.js Edge Runtime and Middleware?
process.env is available in Edge Runtime and Middleware, but with limitations. The Edge Runtime does not have access to the full Node.js process object. Instead, process.env is a statically analyzed object that Next.js populates at build time.
This means NEXT_PUBLIC_ variables work as expected. Server-only variables also work, but only if they are referenced with the literal process.env.VARIABLE_NAME syntax. Dynamic access patterns (like process.env[dynamicKey]) will not resolve. On platforms like Cloudflare Workers, make sure the variables are configured in the platform's environment settings, not just in local .env files.
Environment variables in Next.js are simple once the mental model clicks: server-side by default, client-side only when you opt in with NEXT_PUBLIC_, and never committed to Git when they contain secrets.
Keep a .env.example file up to date as your project grows. Add a validation module that fails the build on missing variables. Set production values in the platform dashboard, not in committed files. Pass NEXT_PUBLIC_ values as Docker build arguments and server secrets as runtime environment variables.
Run through the security checklist before every deployment. A few minutes of verification prevents the kind of credential leak that takes days to remediate.
Related Articles
Husky + Prettier + lint-staged Setup for Next.js
Set up Husky v9, Prettier, and lint-staged in your Next.js project. Step-by-step guide covering pre-commit hooks with the correct 2026 config.
Drizzle ORM Migrations: A Practical drizzle-kit Guide
Learn the full Drizzle ORM migration workflow: push vs migrate, drizzle-kit setup, Turso/libSQL config, team conflicts, and production best practices.