Fix: Next.js Environment Variables Returning undefined
Part of: React & Frontend Errors
Quick Answer
How to fix Next.js environment variables returning undefined — NEXT_PUBLIC prefix rules, server vs client context, .env file loading order, and runtime vs build-time variable access.
The Error
You define environment variables in .env.local but they come back as undefined in your Next.js app:
console.log(process.env.API_KEY); // undefined
console.log(process.env.DATABASE_URL); // undefinedOr a variable is defined but only works on the server:
// In a client component
console.log(process.env.NEXT_PUBLIC_API_URL); // undefined in the browserOr variables work in development but are undefined after deployment.
Why This Happens
Next.js has a strict system for environment variables with rules that differ from plain Node.js:
- Client-side variables must be prefixed with
NEXT_PUBLIC_— without this prefix, variables are server-only and are never sent to the browser bundle. - Variables are inlined at build time — Next.js replaces
process.env.NEXT_PUBLIC_*references with their literal values during the build. If the variable was not set at build time, it isundefinedforever. .envfiles are loaded in a specific order —.env.localoverrides.env,.env.development.localoverrides.env.development, etc.- Server components vs client components — in Next.js 13+ App Router, server components have access to all env vars; client components only have access to
NEXT_PUBLIC_*vars. - Runtime environment vs build environment — on Vercel and similar platforms, env vars set in the dashboard are injected at build time, not runtime (for client vars).
Platform and Environment Differences
The NEXT_PUBLIC_ rule is universal, but how, when, and where environment variables are read changes substantially per host. The same .env.local may produce different process.env values on Vercel, Netlify, self-hosted Docker, and the Edge runtime.
Vercel. Set variables under Settings → Environment Variables, scoped to Production, Preview, and Development. Vercel injects them into the build sandbox for every build, then bakes NEXT_PUBLIC_* into the client bundle. Server variables stay in the runtime container and are readable on every request. Adding or changing a variable does not affect already-deployed builds — you must redeploy. Vercel also encrypts values at rest and exposes them only to functions tied to the same project, so a Preview branch will not see a Production-only value unless you check the Production box.
Netlify. Set variables under Site settings → Build & deploy → Environment. Netlify supports build-time and runtime injection, but for Next.js the same rules apply: NEXT_PUBLIC_* is baked at build, server-only variables are read on each function invocation. Netlify’s deploy contexts (production, deploy-preview, branch-deploy) each have their own variable scope. A common pitfall: variables set at the team level are inherited, but a site-level variable with the same name overrides without warning.
Self-hosted Docker and Kubernetes. When you build the image, NEXT_PUBLIC_* is captured from the build environment and frozen into .next/. To change a public variable you must rebuild the image. Server-only variables can be passed at runtime via docker run -e KEY=value, Kubernetes env, or a ConfigMap. The standalone output (output: "standalone" in next.config.js) drops .env.local from the build artifact, so on the running container only variables from the process environment are visible. If you mount .env.production into the container as a file, Next.js does not automatically load it at runtime; you need an entrypoint script that exports it before starting next start.
Cloudflare Pages, AWS Amplify, Render. Each platform has its own dashboard for build-time variables. Cloudflare Pages additionally distinguishes between build variables (injected during next build) and Pages runtime variables (injected into the Workers runtime that serves your app). Server variables you set in the Workers section are not visible during the build, and NEXT_PUBLIC_* values you set in the Workers section are not baked into the bundle.
App Router vs Pages Router. App Router server components and route handlers read process.env on every request, so a value updated and the process restarted is reflected immediately. Pages Router getStaticProps runs at build time only (without ISR), so changing an env var after build never affects pre-rendered pages until you rebuild. getServerSideProps runs per request like App Router server components.
Edge runtime restrictions. Functions deployed to the Edge runtime (Vercel Edge Functions, Cloudflare Workers, export const runtime = "edge") cannot read arbitrary process.env values at request time the way Node functions can. On Vercel, env vars exposed to Edge functions are explicitly enabled per variable. On Cloudflare, the Workers runtime exposes vars via the env argument passed to the handler, not via process.env. If your code uses process.env.DATABASE_URL inside an Edge function, expect undefined even though Node functions in the same project see the value.
Node 20.6 --env-file vs dotenv. Node 20.6 added the --env-file flag that loads .env-style files without dotenv. Next.js still has its own loader and ignores --env-file; passing it to next dev does not import variables. If you run a custom server (node --env-file=.env.local server.js), Node loads the file before Next.js starts, and Next.js then sees those values in process.env.
Monorepo .env.local location. In a Turborepo or pnpm workspace, .env.local must live next to the app’s package.json, not at the workspace root. apps/web/.env.local is read; ./.env.local at the repo root is not. Some teams symlink the root .env.local into each app, but symlinks across Windows and Unix workspaces are fragile — prefer Turborepo’s globalEnv and per-app .env.local.
Fix 1: Add NEXT_PUBLIC_ Prefix for Client-Side Variables
Any environment variable you need in browser-executed code (components, hooks, client-side API calls) must start with NEXT_PUBLIC_:
Broken — missing prefix:
# .env.local
API_URL=https://api.example.com
STRIPE_PUBLISHABLE_KEY=pk_test_abc123// In a React component (client-side)
const url = process.env.API_URL; // undefined
const key = process.env.STRIPE_PUBLISHABLE_KEY; // undefinedFixed — add NEXT_PUBLIC_ prefix:
# .env.local
NEXT_PUBLIC_API_URL=https://api.example.com
NEXT_PUBLIC_STRIPE_KEY=pk_test_abc123
# Server-only vars (no prefix needed — keep secrets here)
DATABASE_URL=postgres://user:pass@localhost/db
STRIPE_SECRET_KEY=sk_test_xyz789// In a React component
const url = process.env.NEXT_PUBLIC_API_URL; // "https://api.example.com"
const key = process.env.NEXT_PUBLIC_STRIPE_KEY; // "pk_test_abc123"Warning: Never prefix secret keys (
DATABASE_URL,STRIPE_SECRET_KEY,JWT_SECRET) withNEXT_PUBLIC_. They will be embedded in your JavaScript bundle and visible to anyone who inspects the source. Only useNEXT_PUBLIC_for values that are safe to expose publicly.
Fix 2: Understand Where Variables Are Accessible
Next.js has two execution environments:
| Location | process.env.SECRET | process.env.NEXT_PUBLIC_VAR |
|---|---|---|
pages/api/* (API routes) | ✅ Available | ✅ Available |
getServerSideProps | ✅ Available | ✅ Available |
getStaticProps | ✅ Available | ✅ Available |
| Server Components (App Router) | ✅ Available | ✅ Available |
Client Components ("use client") | ❌ undefined | ✅ Available |
| Browser console / DevTools | ❌ undefined | ✅ Visible |
App Router — server component (no prefix needed):
// app/dashboard/page.tsx — server component by default
export default async function DashboardPage() {
const data = await fetch("https://api.example.com/data", {
headers: {
Authorization: `Bearer ${process.env.API_SECRET}`, // Works — server only
},
});
// ...
}App Router — client component (needs NEXT_PUBLIC_):
"use client";
export default function SearchBar() {
const apiUrl = process.env.NEXT_PUBLIC_API_URL; // Works
const secret = process.env.API_SECRET; // undefined — never do this
// ...
}Fix 3: Fix .env File Loading Order
Next.js loads .env files in this order (later files override earlier ones for the same key):
.env— base defaults, committed to git..env.local— local overrides, not committed (add to.gitignore)..env.development/.env.production/.env.test— environment-specific..env.development.local/.env.production.local/.env.test.local— local environment-specific overrides.
Common mistake — variable defined in wrong file:
# .env — committed, used as defaults
NEXT_PUBLIC_API_URL=https://api.example.com
# .env.local — local override (this wins)
# If this file exists but doesn't define NEXT_PUBLIC_API_URL,
# the .env value IS used. But if .env.local defines it as empty:
NEXT_PUBLIC_API_URL=
# Now the variable is an empty string, not the .env valueCheck which file is winning:
# Print all env vars that start with NEXT_PUBLIC
node -e "require('dotenv').config({path:'.env.local'}); console.log(process.env.NEXT_PUBLIC_API_URL)"Best practice file structure:
.env # Safe defaults, committed (no secrets)
.env.local # Local secrets, in .gitignore
.env.production # Production defaults, committed (no secrets)Fix 4: Fix Variables After Deployment (Vercel / CI)
On Vercel, Netlify, and other platforms, environment variables set in the dashboard are available at build time. NEXT_PUBLIC_* variables are baked into the bundle during next build — they are not dynamically injected at runtime.
If you add a variable after deploying, you must redeploy:
- Add the variable in your platform’s dashboard (Vercel → Settings → Environment Variables).
- Trigger a new deployment — the variable is included in the new build.
- The old deployment still has the old build (with
undefinedfor the new variable).
Verify variables are set before build:
# Add to your build script to debug missing vars
echo "NEXT_PUBLIC_API_URL: $NEXT_PUBLIC_API_URL"
next buildFor GitHub Actions:
- name: Build
env:
NEXT_PUBLIC_API_URL: ${{ secrets.NEXT_PUBLIC_API_URL }}
run: npm run buildCommon Mistake: Setting environment variables in the hosting platform’s runtime environment config instead of the build environment config. For
NEXT_PUBLIC_*variables, the build environment is what matters. Runtime-only env vars work for server-side code (API routes, server components) but not for client bundles.
Fix 5: Fix Runtime Environment Variables in App Router
Next.js 13+ App Router supports true runtime environment variables for server components — they are read at request time, not build time:
// app/api/data/route.ts — reads at runtime, not build time
export async function GET() {
const dbUrl = process.env.DATABASE_URL; // Always current value
// ...
}For client-side runtime variables (not baked in at build time), Next.js does not support this natively for NEXT_PUBLIC_* vars. Options:
Option A: Expose vars via an API route:
// app/api/config/route.ts
export async function GET() {
return Response.json({
apiUrl: process.env.API_URL, // Server reads it
});
}// Client component fetches config at runtime
"use client";
const [config, setConfig] = useState(null);
useEffect(() => {
fetch("/api/config").then(r => r.json()).then(setConfig);
}, []);Option B: Use Next.js publicRuntimeConfig (Pages Router only):
// next.config.js
module.exports = {
publicRuntimeConfig: {
apiUrl: process.env.API_URL, // Set at server start, not build time
},
};import getConfig from "next/config";
const { publicRuntimeConfig } = getConfig();
console.log(publicRuntimeConfig.apiUrl);Note: publicRuntimeConfig is not supported in App Router and disables static optimization.
Fix 6: Validate Required Environment Variables
Instead of debugging undefined errors at runtime, validate required variables at startup:
// lib/env.ts — validate at module load time
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`Missing required environment variable: ${name}`);
}
return value;
}
export const env = {
// Server-only
databaseUrl: requireEnv("DATABASE_URL"),
stripeSecretKey: requireEnv("STRIPE_SECRET_KEY"),
// Client-safe (checked at build time)
apiUrl: process.env.NEXT_PUBLIC_API_URL ?? "",
stripePublicKey: process.env.NEXT_PUBLIC_STRIPE_KEY ?? "",
};Using the t3-env library for type-safe env validation:
npm install @t3-oss/env-nextjs zod// env.ts
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
server: {
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().min(1),
},
client: {
NEXT_PUBLIC_API_URL: z.string().url(),
},
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
},
});This validates all env vars at startup with Zod schemas, throwing a descriptive error if any are missing or malformed.
Still Not Working?
Restart the development server. Next.js reads .env files at startup. After adding or changing .env.local, stop next dev and restart it — changes to .env files are not hot-reloaded.
Check for typos in variable names. process.env.NEXT_PUBLIC_API_URL and process.env.NEXT_PUBLIC_API_url are different. Environment variable names are case-sensitive.
Check for spaces around = in .env files. API_KEY = value (with spaces) is invalid in most .env parsers — use API_KEY=value (no spaces).
Check for quotes in .env files. Some .env parsers strip quotes, others do not. API_KEY="my value" may result in "my value" (with quotes) depending on the parser. Next.js strips surrounding quotes, but be careful with nested quotes.
Check next.config.js for env overrides. Values defined in next.config.js under env take precedence over .env files:
// next.config.js
module.exports = {
env: {
NEXT_PUBLIC_API_URL: "https://hardcoded.example.com", // Overrides .env.local
},
};Check for destructuring at the top of a file. const { API_KEY } = process.env; runs once when the module is imported. In dev with hot reload this is usually fine; in production a server-only variable destructured at module top is captured into the build chunk, and Vercel’s optimizer may shake it as a constant. Read process.env.API_KEY inside the function that needs it rather than destructuring at the top.
Check that .env.local is not being skipped in CI. Many CI providers do not commit .env.local to the build context. The build picks up values from CI dashboard variables only. If your CI build is missing variables, confirm the dashboard has them rather than expecting .env.local to be deployed.
Check for stale build caches on Vercel. A Preview deployment may use a cached .next/ from a previous commit if you only changed the env var and not source code. Trigger a redeploy with “Redeploy without build cache” or push a no-op commit to force a fresh build.
Check for process.env access inside instrumentation.ts. Next.js calls instrumentation.ts very early in the lifecycle. Some platforms inject env vars after this hook runs. If observability tooling reports missing variables, move the access into the function body, not the module top.
For general .env loading issues outside of Next.js, see Fix: .env variables not loading. For env loading inside Vite-based projects, see Fix: Vite environment variables not working. For Docker Compose env propagation problems that masquerade as Next.js bugs, see Fix: Docker Compose env file not loaded. For build failures triggered by missing env vars, see Fix: Next.js build failed.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: React Hydration Error — Text Content Does Not Match
How to fix React hydration errors — server/client HTML mismatches, useEffect for client-only code, suppressHydrationWarning, dynamic content, and Next.js specific hydration issues.
Fix: Next.js App Router Fetch Not Caching or Always Stale
How to fix Next.js App Router fetch caching issues — understanding cache behavior, revalidation with next.revalidate, opting out with no-store, cache tags, and debugging stale data.
Fix: Next.js CORS Error on API Routes
How to fix CORS errors in Next.js API routes — adding Access-Control headers, handling preflight OPTIONS requests, configuring next.config.js headers, and avoiding common proxy mistakes.
Fix: Next.js Middleware Not Running (middleware.ts Not Intercepting Requests)
How to fix Next.js middleware not executing — wrong file location, matcher config errors, middleware not intercepting API routes, and how to debug middleware execution in Next.js 13 and 14.