Why does your app work on localhost but break in production?
The short answer
Your code is probably fine. What changed is the environment it’s running in, and five gaps between localhost and production account for almost every “works here, broken there” bug. In roughly the order you’ll actually hit them: an environment variable that exists on your machine but was never added to the host, a production database that’s different from (or unreachable from) the one you tested against, a URL hardcoded to localhost, a missing CORS header now that frontend and backend live on separate domains, and a stricter production build catching something the dev server quietly let through. Open your host’s deployment logs before you touch any code. The first real error line almost always names one of these five, so you rarely need to guess.
The 5 causes, in the order you’ll actually hit them
1. A missing environment variable. This is the single most common cause, because a .env file never leaves your laptop. It’s gitignored on purpose, so when your host builds and runs your app straight from your git repo, none of the values inside that file come along for the ride. Symptom: an error mentioning undefined, a key name, or something like Missing STRIPE_SECRET_KEY, right after deploy, for a feature that worked a minute ago under npm run dev. Check: open your host’s dashboard and confirm every variable in your local .env also exists there, spelled exactly the same, set for the Production environment specifically. Adding or editing a variable there only applies to your next deployment, never the one already running, so redeploy after you fix it.
2. A different database. Locally you’re likely pointed at a dev database, a Docker container, or a lightly-configured free tier. Production needs its own connection string, and that string is itself one of the environment variables from cause #1, which is why these two travel together in practice. Symptom: the app loads, but data is missing or empty, or a connection times out instead of failing with a clean error. Check: confirm the production connection string actually points at your production database, and that the database accepts connections from wherever your host runs its functions. Serverless platforms don’t hand you a fixed outbound IP address by default (on Vercel, a static IP is a paid add-on), so a database with a strict IP allowlist will quietly refuse a deployed function even though it happily accepted your laptop’s IP. A pooled connection string, the kind Supabase or Neon give you, usually avoids this entirely.
3. A hardcoded URL. Somewhere, a fetch call, a redirect, or an image source has http://localhost:3000 typed directly into it instead of reading from a variable that adapts to wherever the app is running. It’s invisible while you’re developing because localhost:3000 is exactly where you’re looking. Symptom: a network request that fails outright, a redirect that tries to send a real visitor back to your own laptop, or an image that loads for you and nobody else. Check: search your codebase for the literal string localhost outside config files and READMEs, and confirm the API base URL and any redirect targets read from an environment variable instead.
4. CORS. Once your frontend and backend live on two different real domains, the browser starts enforcing a rule it mostly ignored on localhost, where both pieces sometimes shared an origin during dev. Symptom: a red CORS error naming Access-Control-Allow-Origin in the browser console, while the same API endpoint works fine in Postman or curl. Check: confirm the backend’s CORS configuration lists your actual production frontend domain, and that you didn’t leave a wildcard * in place. Browsers reject a wildcard outright for any credentialed request, the kind sent with cookies or browser-handled HTTP authentication, not a plain bearer token you attach yourself in a header.
5. Build vs dev differences. npm run dev is forgiving by design, so it stays fast while you’re saving files every few seconds. npm run build, which is what most hosts run automatically as part of a deploy, is the strict version of the same process, and it catches things dev mode let slide. Symptom: the deploy log shows a build failure, a type error, a page that fails to pre-render, for code that ran without complaint while you were developing. Check: run npm run build locally before you push, so you catch that gap on your own machine instead of inside a live deploy log.
The diagnostic, step by step
- Open the actual deployment log on your host, not just the browser tab. Read the first real error line, not the generic “failed” banner sitting above it.
- If the log names a variable, that’s cause #1. Add it in the host’s dashboard for the Production environment, then redeploy rather than just saving and refreshing.
- If the app builds and loads but the data is wrong or a connection times out, check the production connection string and whether the database’s allowlist actually covers your host’s traffic.
- If a request fails with a blank network error pointing at an unexpected address, grep the codebase for
localhost. - If the browser console shows a CORS error, rather than a generic network failure, the fix belongs in the backend’s headers, not in how the frontend calls fetch.
- If none of that fits and the build itself failed, run the build command locally and read that error directly instead of the deploy log’s summary of it.
- If you’re still stuck after a few minutes of this, roll back to the last working deployment and debug from a stable base, instead of stacking a new guess on top of the last one.
Two things that make this take longer than it should
Caching can hide a fix you already made. If you recently connected a custom domain, an old DNS answer or a CDN’s cached copy can keep serving the previous version for a while after the real fix has already shipped, which looks exactly like the fix failing.
NEXT_PUBLIC_-style variables (Next.js and most frameworks have an equivalent) get baked into the JavaScript bundle at build time, not read live at runtime. Edit one in the dashboard and redeploy without triggering a fresh build, and the old value ships anyway: it stopped being a variable the moment the build ran and became a literal value inside the bundle.
What Glim would tell you
This is exactly the kind of gap Glimfly built Glim for: a small companion that narrates what your agent is actually doing while it deploys, not after a stranger’s bug report tells you something broke. Instead of re-reading a deploy log line by line hoping to spot the one that matters, you’d see the moment a variable came up missing or a build caught something dev mode didn’t. Check the dictionary for any term above that moved faster than your agent explained it.
People also ask
“My app worked in npm run dev, why does it throw a 500 or show a blank page in production?”
Almost always one of five gaps between the two environments: an environment variable that's missing on the host, a production database it can't reach or was never pointed at, a URL hardcoded to localhost, a missing CORS header now that frontend and backend live on separate domains, or a stricter production build catching something the dev server let slide. Start with your host's deployment logs. The first real error line usually names which one it is.
“Do I need to redeploy after adding or fixing an environment variable?”
Yes. On Vercel and most serverless hosts, a change to an environment variable only applies to your next deployment. It doesn't retroactively fix a deployment that's already running. If you've just added a missing key in the dashboard, trigger a fresh deploy before assuming the fix didn't work.
“Why does my database connection work on my laptop but fail once deployed?”
Two separate things usually cause this: the production connection string was never set on the host, so the app is still pointed at your local database, or your database has an IP allowlist that trusts your home network but not your host's servers. Serverless functions don't get a fixed outbound IP address by default on most platforms, so a strict allowlist can silently block a deployed app that worked fine locally. A pooled connection string (Supabase, Neon) usually sidesteps the second problem.
Words from this guide
Localhost vs production: why your app works on your machine but breaks online
Localhost is your app running privately on your own machine; production is the live version everyone else can actually use, and the two don't always behave the same.
filesWhat is an environment variable? (and why your app has a .env file)
An environment variable is a named value your app reads from its surroundings at runtime, instead of having it hardcoded, so secrets and settings can change without touching a line of code.
webWhat is CORS and why is it blocking your app?
CORS is the browser's rule that blocks your frontend's JavaScript from reading a response from a different origin unless the server explicitly says it's allowed.
webnpm run dev vs npm run build: why your agent runs one to code and the other to ship
npm run dev starts a local server that reloads instantly as you edit, while npm run build compiles a minified, optimized version of your app for production, and running one is never a substitute for the other.
webCustom domains and DNS (why your site takes hours to go live)
DNS is the address book that tells the internet which server your domain name actually points to, and the wait after you set it up is computers around the world clearing their old cached answer, not your domain "travelling" anywhere.
Want this explained about your own project?
Glim watches your coding agent work and explains your sessions in plain words, live. Join the waitlist to get it first.