Local npm run dev is green, but deploying to Vercel / Netlify / Cloudflare Pages produces a 500, or the frontend throws Cannot read properties of undefined / API key is required. Almost always the cause is an environment variable that exists in your local .env but never reaches the production runtime. Two things conspire: .env files are (correctly) gitignored, so the host has no way to learn about them unless you set them in its dashboard; and frameworks like Astro, Next.js, and Vite enforce strict prefix rules about which vars get inlined into the client bundle, replacing the rest with undefined at build time.
Fastest fix: add the variable in your host’s Production environment, confirm it carries the correct client prefix if the browser reads it, then redeploy with the build cache cleared. The five sections below are ranked by hit rate so you can stop at the first match.
Which bucket are you in?
| Symptom | Most likely cause | Jump to |
|---|---|---|
Server route 500s; process.env.X is undefined in function logs | Var not set on the host | Cause 1 |
Works server-side, undefined only in the browser | Missing client prefix | Cause 2 |
| Var is set, but redeploy was suspiciously fast | Stale build cache | Cause 3 |
| Some env names resolve, this one never does | Name typo / whitespace / case | Cause 4 |
Single app works, monorepo turbo build fails | Turborepo not forwarding the var | Cause 5 |
Common causes
Ordered by hit rate, highest first.
1. The variable is never set on the host
You have a .env.local locally, but the host’s Environment Variables panel is empty for Production (or the var is only set for Preview / Development). Since .env files are not committed (and should not be), the host literally does not know about them.
# Vercel CLI — list what is actually on production
vercel env ls production
# Expect to see your variable names; if missing, this is the cause
How to spot it: Runtime error process.env.X is undefined in the function/server logs, or the build log’s resolved env list does not include the variable name.
2. Client-side code reads a var without the required prefix
Each framework exposes only prefixed vars to the browser bundle. Anything else is silently replaced with undefined at build time, so server-side code sees the value but the browser does not:
| Framework | Client-exposed prefix | Access in code |
|---|---|---|
| Next.js | NEXT_PUBLIC_ | process.env.NEXT_PUBLIC_X |
| Astro | PUBLIC_ | import.meta.env.PUBLIC_X |
| Vite / SvelteKit | VITE_ | import.meta.env.VITE_X |
| Create React App | REACT_APP_ | process.env.REACT_APP_X |
| Remix / React Router | none — return it from a loader | from loader data |
// Inside an Astro component
const wrong = import.meta.env.API_URL; // undefined (no prefix)
const right = import.meta.env.PUBLIC_API_URL; // works in browser and server
Per the Astro docs, only PUBLIC_-prefixed vars reach client code; everything else (such as DATABASE_URL) is server-only and becomes undefined in the client bundle. Newer Astro projects can also declare typed vars in astro:env with an explicit context ("client" / "server") and access ("public" / "secret"), which fails the build on a missing required var instead of silently inlining undefined.
How to spot it: Open DevTools → Console on the live page and evaluate import.meta.env (Vite/Astro) or window.__NEXT_DATA__ (Next.js). If the variable is not there, the framework filtered it out. A server-only secret correctly should not appear here.
3. Build cache reused the previous output
You added a new var and redeployed, but the platform reused the prior build output and your new var never participated in compilation. This is common when you “only change env, do not change code,” because some platforms skip a fresh build when the Git SHA is unchanged.
How to spot it: The build log says Restored build cache (Vercel) and the deploy completes in seconds instead of minutes.
4. Variable name typo, whitespace, or case mismatch
.env has STRIPE_SECRET_KEY but the code reads STRIPE_SECRET; or the dashboard entry has a trailing space in the name. Env var names are case-sensitive, and dashboard UIs happily hide a stray space.
How to spot it:
# Temporary debug print in a serverless function (delete after — do not ship)
console.log(Object.keys(process.env).filter(k => k.includes("STRIPE")))
If the key prints as "STRIPE_SECRET_KEY " with a trailing space, or not at all, you have found it.
5. Monorepo (Turborepo / Nx) does not pass the var through
Root .env has the var, but a child workspace’s next build does not see it. As of Turborepo 2.x, tasks run in strict environment mode by default, so a task only receives the env vars you declare. You must list them under the tasks key (the old pipeline key was removed in v2):
// turbo.json (Turborepo 2.x)
{
"$schema": "https://turborepo.com/schema.json",
"globalEnv": ["NODE_ENV"],
"tasks": {
"build": {
"env": ["DATABASE_URL", "NEXT_PUBLIC_API_URL"]
}
}
}
Use env for vars a specific task needs (changes to them bust that task’s cache) and globalEnv for vars that affect every task. If you are still on Turborepo 1.x, the same config lives under pipeline instead of tasks.
How to spot it: cd apps/web && npm run build works locally, but turbo build from the root fails — the var is not crossing the task boundary.
Shortest path to fix
Work in order: confirm the var is on the host, confirm the prefix matches the framework, force a cacheless rebuild.
Step 1: Add the variable to the Production environment
UI paths as of June 2026 (vendors rename these menus often, so match by the labels not the exact clicks):
- Vercel: Project → Settings → Environment Variables → enter Name and Value → choose the environments (check Production) → Save. For secrets, toggle Sensitive before saving.
- Netlify: Project configuration → Environment variables → Add a variable. To set a per-context value, pick the Production deploy context; for secrets, check Contains secret values.
- Cloudflare Pages: Workers & Pages → your project → Settings → Variables and Secrets → Add → set Name and Value → click Encrypt to store it as a secret. Set it for the Production environment.
- Firebase (Cloud Functions): the legacy
firebase functions:config:setwas shut down on Dec 31 2025. Use a.envfile in your functions directory for plain config, anddefineSecret()(backed by Cloud Secret Manager) for secrets.
# Vercel CLI works too
vercel env add STRIPE_SECRET_KEY production
# It will prompt for the value interactively
Mark API keys, DB passwords, and tokens as Sensitive/Secret. On Vercel, a Sensitive value cannot be viewed later in the dashboard or via vercel env ls, and if it is 32+ characters and appears in a build log, Vercel replaces it with [REDACTED].
Step 2: Add the framework’s client prefix where required
Use the right prefix in both the var name and the code:
# .env.production
NEXT_PUBLIC_API_URL=https://api.example.com # Next.js client
PUBLIC_API_URL=https://api.example.com # Astro client
VITE_API_URL=https://api.example.com # Vite client
DATABASE_URL=postgres://... # server only, no prefix
In code:
// Next.js
const url = process.env.NEXT_PUBLIC_API_URL;
// Astro / Vite
const url = import.meta.env.PUBLIC_API_URL;
The host dashboard variable name must match the prefix exactly — no abbreviating. Never put a server secret behind a public prefix; that ships it to every visitor’s browser.
Step 3: Redeploy with the build cache cleared
# Vercel CLI
vercel --prod --force
Or in the dashboard:
- Vercel: Deployments → most recent → ⋯ → Redeploy, then uncheck “Use existing Build Cache”.
- Netlify: Deploys → Trigger deploy → “Clear cache and deploy site”.
- Cloudflare Pages: Deployments → Retry deployment (Pages rebuilds without cache).
Step 4: Add a startup schema check to fail fast
Validate required env at startup with Zod so a missing var crashes the build / boot rather than producing a runtime 500 on a user request:
// src/env.ts
import { z } from "zod";
const schema = z.object({
DATABASE_URL: z.string().url(),
STRIPE_SECRET_KEY: z.string().startsWith("sk_"),
NEXT_PUBLIC_API_URL: z.string().url(),
});
export const env = schema.parse(process.env);
A missing var now throws ZodError during build/boot — far easier to debug than a crash on a live request.
Step 5: Verify the live site is actually reading the value
After deploy:
# Server-side var: hit an API route that consumes it
curl -s https://yourdomain.com/api/healthz
# Expect 200 with env-derived data in the response
# Client-side var: grep the served HTML / bundle to confirm inlining
curl -s https://yourdomain.com/ | grep -o "https://api\.example\.com" | head -1
If the server route returns 200 and the grep prints your URL, the value is live. A server secret should not appear in the second command — if it does, you accidentally gave it a public prefix.
How to confirm it’s fixed
- The Production env panel lists the variable for the Production environment.
- The redeploy ran a full build (no
Restored build cacheline). - The server route that uses the var returns 200.
- For a client var, it is visible in
import.meta.env/window.__NEXT_DATA__on the live page; for a server secret, it is correctly absent there.
FAQ
Why does it work locally but not in production? Your local .env / .env.local is read automatically by the dev server, but it is gitignored and never deployed. Production reads only what you set in the host’s dashboard or CLI, so an unset var is the default state in prod.
Do I need to redeploy after adding an env var? Yes. Environment variables are read at build time (for inlined client vars) or boot time (for server vars). An existing running deployment keeps its old values until you trigger a new deploy — and for added safety, clear the build cache so a fresh build picks them up.
Why is my NEXT_PUBLIC_ / PUBLIC_ variable still undefined in the browser? Either the var was not present at build time (set it on the host, then rebuild) or the dashboard name does not exactly match the prefixed name in your code. These vars are inlined during the build, so changing them in the dashboard without rebuilding has no effect.
Is it safe to put an API key behind NEXT_PUBLIC_? No. Anything with a public prefix is inlined into the JavaScript that every visitor downloads. Keep keys server-only (no prefix) and read them in API routes, server components, or functions. Use a public prefix only for non-secret values like a public API base URL.
My monorepo build fails on Vercel but works locally — why? Turborepo 2.x defaults to strict env mode and only passes vars you declare in turbo.json. Add the var to the relevant task’s env array (or globalEnv if it is global), commit, and redeploy.
Prevention
- Maintain a
.env.examplein the repo listing every required variable name (no values); README documents which are required vs optional. - Validate env at startup with Zod / Valibot / Envalid so a missing var fails the build instead of a user request.
- Add a CI
check-envstep that diffs.env.exampleagainst what is set on the host and fails if anything is missing. - Separate
.env.public(all client-prefixed) from.env.secrets(all server-only) so accidental client-side leakage is visible in review. - After any env change, default to a force-rebuild (
--force/ “Clear cache”) rather than relying on whoever clicks Deploy to remember.
Related
Tags: #Hosting #Debug #Troubleshooting