The classic pain: npm run dev is perfect locally. Deploy to Vercel / Netlify / Cloudflare Pages / Render and the build fails, the runtime crashes, or the page loads but every feature is broken. “Works on my machine” is the oldest joke in software, and the root cause is almost always the same family: implicit local assumptions don’t hold in production.
Fastest fix (start here): run the production build locally with npm run build && npm start (or npm run preview for Vite). Roughly 90% of “works locally, fails in prod” reproduces on your own machine in under a minute — which means it’s a build or code bug, not a deployment mystery. If it builds and runs clean locally but still fails deployed, the difference is environment: env vars, Node version, or Linux case-sensitivity, in that order of likelihood.
This is a diffing job, not a guessing job. Work the five axes below: env vars → runtime version → filesystem → network/CORS → build process.
First, find your bucket
Match the production error string to the most likely cause before you start editing anything.
| Symptom in prod logs / console | Most likely cause | Jump to |
|---|---|---|
Cannot read properties of undefined, Invalid URL, DB connection refused | Env var missing in prod | Step 2 |
X is not a function, SyntaxError: Unexpected token, Unsupported engine | Node/Python version mismatch | Step 3 |
Module not found / Cannot find module './Button' (builds fine locally) | Linux case-sensitivity | Step 4 |
EROFS: read-only file system, ENOENT on a path you wrote at runtime | Local-only filesystem/state | Step 5 |
Works in dev, breaks only after build (blank page, hydration error) | Dev server vs prod build | Step 6 |
CORS error, 401/403, cookie not set, OAuth redirect mismatch | Origin/cookie/key differences | Step 7 |
| Timestamps off by N hours | Timezone/locale | Step 8 |
Common causes, by hit rate
1. Env vars not set in production
The single most common cause. Your local .env has DATABASE_URL, STRIPE_SECRET_KEY, etc., but the deploy platform never received them, so process.env.X is undefined at runtime. A second, sneakier variant: the var is set, but only for the Preview environment and not Production (Vercel scopes vars per environment), or a browser-exposed var is missing the required prefix (NEXT_PUBLIC_, VITE_, PUBLIC_) so the client bundle ships without it.
How to spot it: prod logs show Cannot read properties of undefined, Invalid URL, or a database/Redis connection refusal at boot.
2. Node / Python version mismatch
Local Node 24, prod on an older major — or the reverse. As of June 2026 the platform defaults have moved up, so the old “prod is stuck on Node 18” assumption is usually wrong now, but a pinned old version in a .nvmrc or project setting still bites:
- Vercel: offers
24.x(default),22.x,20.x. Node 18.x is deprecated. - Cloudflare Pages: the V3 build image defaults to Node 22 (it replaced the old Node 18 default in 2025).
- Render: new services default to Node 22.10.0; existing services keep whatever they were created with.
- Netlify: honors
.nvmrc/NODE_VERSION; current default tracks an active LTS.
ES2023+ syntax and APIs (Array.prototype.toSorted, findLast, top-level await in certain configs) exist on Node 20+ but not 18, so a service still pinned to 18 will crash on modern code.
How to spot it: prod logs show X is not a function, SyntaxError: Unexpected token, or the platform refuses the build with Unsupported engine / EBADENGINE.
3. Filesystem differences (case sensitivity, path separators)
Your laptop lies to you. macOS (APFS, default) and Windows (NTFS) are case-insensitive; Linux — which every major host runs in production — is case-sensitive.
// macOS / Windows: case-insensitive, this resolves
import './components/Button'; // file on disk is button.tsx — still works locally
// Linux prod: case-sensitive, this throws
import './components/Button'; // Error: Cannot find module './components/Button'
The same trap hits asset paths (/Logo.png vs /logo.png) and any string path you read at runtime.
How to spot it: prod build errors with Module not found / Cannot find module, but npm run build is clean on your Mac/Windows machine.
4. Local-only services (filesystem, in-memory state)
Locally you do fs.writeFile('./uploads/x.jpg') or keep a cache in a module-level variable. On serverless platforms (Vercel Functions, Cloudflare Workers/Pages Functions) the filesystem is read-only except for /tmp, and each invocation may hit a different cold instance — so in-memory state silently resets and writes throw.
How to spot it: EROFS: read-only file system, ENOENT, or “the upload worked once then 404s,” or a cache/session that randomly empties.
5. Dev server vs prod build behave differently
Vite / Next / Webpack dev mode runs unbundled with HMR and loose parsing. The prod build tree-shakes, minifies, and (for Next/Astro) server-renders. Code that leans on dev-only behavior — reading import side effects, window during SSR, non-deterministic component output — breaks only after build.
How to spot it: npm run dev is fine, but npm run build && npm start reproduces a blank page, a React hydration mismatch, or a window is not defined SSR error.
6. CORS / cookie domain / third-party keys
localhost:3000 and yourdomain.com are different origins, so they hit different CORS allowlists, different cookie SameSite/Secure behavior (Secure cookies are dropped on plain http://localhost unless you opt in), and different OAuth redirect-URI allowlists. A frequent miss: dev used the provider’s test keys and the redirect URI registered in the OAuth app only lists localhost.
How to spot it: prod Network tab shows a CORS error, a 401/403, “cookie not set,” or an OAuth redirect_uri_mismatch that never appeared in dev.
7. Timezone / locale
new Date().toString() renders in your laptop’s local zone; the prod server runs in UTC. Every server-formatted timestamp is then off by your UTC offset.
How to spot it: dates/times are consistently shifted by a fixed number of hours in prod.
Shortest path to fix
Step 1: Reproduce the prod build locally
Run the actual production build before you touch the deploy platform:
# Next.js
npm run build && npm start
# Vite (React/Vue/Svelte SPA)
npm run build && npm run preview
# Astro / generic Node server
npm run build && NODE_ENV=production node ./dist/server/entry.mjs
If it fails here, it’s a build/code issue, not deployment — fix it locally where the feedback loop is fast. If it’s clean, the gap is environmental; continue below.
Step 2: Diff env vars (the reliable way)
Don’t hand-transcribe the dashboard. On Vercel, pull the deployed env into a local file and diff it against your working .env:
# Pull production env from Vercel into .env.production.local
vercel env pull .env.production.local --environment=production
# Normalize both to KEY names and compare what exists where
comm -3 \
<(grep -oE '^[A-Z0-9_]+' .env | sort -u) \
<(grep -oE '^[A-Z0-9_]+' .env.production.local | sort -u)
Anything that prints on the left exists locally but is missing in prod. Add the missing ones:
vercel env add DATABASE_URL production
vercel env add STRIPE_SECRET_KEY production
# CLI prompts you to paste the value (it is not echoed)
Then redeploy — env var changes do not apply to existing deployments. Checklist for the common gotchas:
- Set for the right environment (Production, not just Preview/Development).
- Browser-exposed vars carry the framework prefix:
NEXT_PUBLIC_,VITE_,PUBLIC_(Astro/SvelteKit). - Multiline secrets (private keys) keep their newlines — paste, don’t retype.
On other platforms, validate from the dashboard: Render → service → Environment; Cloudflare Pages → Settings → Variables and Secrets; Netlify → Site configuration → Environment variables.
Step 3: Pin the runtime version
Make local, CI, and prod agree on one Node major. Pin it in package.json and a version file:
// package.json — Vercel/Netlify/Cloudflare read engines.node
{
"engines": {
"node": "22.x"
}
}
// .nvmrc (also accepted: .node-version)
22
Platform specifics as of June 2026:
- Vercel deploys the latest patch of the major you request (e.g.
22.x→ newest 22).engines.nodeoverrides the dashboard setting. Verify withnode -vin the Build Command, or logprocess.versionat runtime. - Cloudflare Pages resolves in this order:
.nvmrc/.node-version→NODE_VERSIONbuild env var → default (Node 22 on the V3 image). - Render reads
NODE_VERSION(env var) or.node-version; accepts a semver or alias likelts.
Step 4: Fix Linux case sensitivity
Find imports whose casing doesn’t match the file on disk:
# List relative imports, then eyeball against actual filenames
grep -rEn "from '\.\./|from '\./" src/ | sort -u | head -50
ls -la src/components/
To catch it automatically and reliably, run the build in a Linux container that matches prod (a one-line dry run, no APFS volume surgery needed):
docker run --rm -v "$PWD":/app -w /app node:22-slim sh -c "npm ci && npm run build"
If that build fails with Cannot find module while your local build passes, you’ve found a casing bug. Lint it permanently with eslint-plugin-import (import/no-unresolved) and rename files/imports to match exactly.
Step 5: Replace local-only dependencies
Swap anything that assumes a writable, persistent local box for a cloud-native equivalent:
| Local (works on your machine) | Production replacement |
|---|---|
fs.writeFile to ./uploads | S3 / Cloudflare R2 / Cloudinary |
| in-memory cache / global var | Redis (Upstash) / Cloudflare KV |
| local SQLite file | Postgres (Neon/Supabase) / Turso |
local cron / setInterval | Vercel Cron / Cloudflare Cron Triggers / Inngest |
| secrets read from a local file | platform env vars / a secrets manager |
On serverless, if you must touch disk, only /tmp is writable and it is ephemeral per invocation.
Step 6: Make dev and prod behavior match
Don’t fork logic on NODE_ENV — it guarantees dev can’t catch prod bugs:
// Anti-pattern: dev never exercises the prod path
if (process.env.NODE_ENV === 'development') {
// ...
}
// Better: an explicit, testable flag you can flip in staging
const useMock = process.env.USE_MOCK === 'true';
Then run a staging/preview deploy with the production flag set and validate there before merging. For SSR frameworks, guard browser-only globals: if (typeof window !== 'undefined') before touching window/document.
Step 7: Reconcile CORS, cookies, and OAuth for the prod origin
Your prod origin (https://yourdomain.com) is not the dev origin (http://localhost:3000), so anything keyed to origin has to be re-registered:
- CORS allowlist: add the prod origin to the server’s
Access-Control-Allow-Origin(and any preflight config), not justlocalhost. If you send cookies cross-origin, setAccess-Control-Allow-Credentials: trueand use an explicit origin, not*. - Cookies: prod is HTTPS, so set
Secureand pick aSameSitethat fits —SameSite=None; Securefor cross-site,Laxfor same-site. A cookie that worked onhttp://localhostcan silently drop in prod ifSecureis required but the request isn’t HTTPS, or if theDomainis wrong. - OAuth: add the prod callback (
https://yourdomain.com/api/auth/callback/...) to the provider’s redirect-URI allowlist, and switch from the provider’s test keys to live keys.
Verify: load the prod site, reproduce the auth/fetch flow, and confirm the Network tab shows no CORS block, no redirect_uri_mismatch, and the session cookie is set with the expected Secure/SameSite attributes.
Step 8: Standardize on UTC
// Server: store and transport as UTC ISO strings
const iso = new Date().toISOString(); // e.g. 2026-06-21T08:00:00.000Z
// Client: format to the user's zone at display time only
new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York' }).format(new Date(iso));
Set TZ=UTC on the server too, so any stray local-time formatting is at least consistent.
How to confirm it’s fixed
- The local prod build (
npm run build && npm start) passes clean. vercel env pulldiff shows zero keys missing from production.node -vin the build log (orprocess.versionat runtime) matches your pinned major on every environment.- The Linux container build (
docker run … node:22-slim … npm run build) succeeds. - Redeploy, then re-trigger the exact action that failed. The original error string is gone from the production logs and the browser console is clean.
FAQ
Why does it build locally but fail only on the deploy platform?
Your dev machine is case-insensitive (macOS/Windows) while the deploy runs on case-sensitive Linux, and it has env vars your prod project doesn’t. Reproduce both with a node:22-slim Docker build plus a vercel env pull diff and the gap usually shows up immediately.
npm run dev works but npm run build fails — what’s different?
The dev server runs unbundled with loose parsing; the prod build tree-shakes, minifies, and (for SSR frameworks) server-renders. Common culprits: touching window/document during SSR, React hydration mismatches, and import side effects that only the bundler drops. Reproduce with npm run build && npm start locally.
I added the env var on Vercel but it’s still undefined.
Env var changes only apply to new deployments, so trigger a redeploy. Then confirm it’s set for Production (not only Preview), and that any client-side var uses the framework prefix (NEXT_PUBLIC_ / VITE_ / PUBLIC_) — without it the value never reaches the browser bundle.
How do I pin the same Node version everywhere?
Put "engines": { "node": "22.x" } in package.json and 22 in .nvmrc. Vercel, Netlify, and Cloudflare Pages all read these. In GitHub Actions use actions/setup-node@v4 with node-version-file: .nvmrc. Verify with node -v in each build log.
My timestamps are off by exactly N hours in production.
The prod server runs in UTC and your laptop doesn’t. Store and transport new Date().toISOString(), set TZ=UTC on the server, and convert to the user’s timezone only at display with Intl.DateTimeFormat({ timeZone }).
Prevention
- Run
npm run build && npm startbefore every PR — never trust the dev server alone. - Validate env vars with
zod(or similar) at boot so a missing key fails fast with a clear message instead of crashing deep in a request. - Pin
engines.nodeinpackage.jsonand ship a matching.nvmrc; have CI useactions/setup-node@v4withnode-version-file: .nvmrc. - Mac users: run the build inside a Linux container (
node:22-slim) to surface case-sensitivity bugs before deploy. - Replace any local-only code (
fs.write, in-memory state) with a cloud equivalent on day 1. - Keep a preview/staging environment whose config mirrors prod, and validate every PR there before merge.
- Store all times as UTC ISO strings; convert to a timezone only at display.
- Put the whole team on one
.nvmrc/.python-versionto retire “works on my machine.”
Related
Tags: #Backend #Debug #Troubleshooting