Localhost Is Not Production: Diagnosing React Apps That Break on Deploy
Learn why a React app that runs fine on your machine fails once deployed, and how to trace API URLs, env vars, CORS, routing, assets and auth to the failing layer.
This article is published in English.
Sources: Why Your React App Works Locally but Breaks After Deployment: 10 Common Problems and How to Fix Them
A React app that behaves perfectly on localhost and then falls apart the moment it is deployed is one of the most familiar frustrations in frontend work. Requests stop returning data, refreshing a page yields a 404, images vanish, login silently fails, or the hosting platform refuses to build code that compiled fine on your laptop. Almost none of these failures come from React itself. They come from the gap between the environment you develop in and the one your users actually hit. This guide walks through the ten most common causes, shows how each one presents itself, and ends with a debugging method and a pre-deploy checklist you can reuse on every release.
Why the same code behaves differently in production
A React frontend is rarely the whole application. It talks to an API, the API talks to a database, and every link in that chain depends on configuration: environment variables, domain names, network rules, authentication settings and whatever the hosting platform does with incoming requests. On your machine, you control all of that implicitly. You know which port the backend listens on, which .env file is loaded and which packages are sitting in node_modules. In production, each of those assumptions is replaced by something explicit, and any mismatch can make correct code look broken.
Keep that framing in mind as you read the sections below. Most of the fixes are configuration changes, not code changes.
1. The API URL still points at localhost
When a deployed app shows no data, the first thing worth checking is where its requests are going. During development it is natural to run the backend locally and hard-code its address in the frontend:
const API_URL = "http://localhost:8080/api";
That works because your browser and your backend run on the same computer. Once the frontend is deployed to something like https://my-app.example.com, the same line keeps sending requests to http://localhost:8080/api. The catch is that localhost is resolved by whichever browser runs the code. For a visitor, it means their own machine, not your development box. The request path effectively becomes:
User's Browser
↓
localhost:8080
↓
User's computer
Unless that visitor happens to have your backend running on port 8080, every request fails.
The fix is to stop hard-coding the address and read it from an environment variable. In a Vite project, you declare the value in an env file:
VITE_API_URL=https://your-backend.example.com/api
and read it in your code through import.meta.env:
const API_URL = import.meta.env.VITE_API_URL;
Now development can point at http://localhost:8080/api while the production build points at something like https://api.example.com/api, with no code change between the two. It is a tiny refactor, but it removes one of the most frequent deployment mistakes: asking a stranger's browser to talk to your laptop.
A related safeguard is to fail loudly when the variable is absent. If API_URL ends up undefined, requests go to paths like undefined/users, which is confusing to diagnose. A one-line check at startup that logs or throws when the value is missing saves time later.
2. Production environment variables were never configured
Environment variables are the next usual suspect. Locally, you probably have a .env file that holds everything the app needs:
VITE_API_URL=http://localhost:8080/api
VITE_APP_NAME=MyApp
Those values exist only on your computer. A hosting platform does not read your local .env file (and it should not, since that file is typically excluded from version control). Your production build has to receive its own values, configured in the provider's environment-variable settings:
VITE_API_URL=https://your-backend.example.com/api
After adding or changing a value there, you need to trigger a fresh deployment. With Vite, this step is not optional: only variables prefixed with VITE_ are exposed to client code, and Vite substitutes them into the bundle at build time. Changing a variable in the dashboard does nothing for a bundle that was already built with the old value.
Frontend variables are not secrets
There is a security lesson hidden in this. Putting a value in .env does not make it private. Anything exposed to the client is compiled into JavaScript that every visitor downloads and can read. Database passwords, private API keys and other server-side credentials must never live in VITE_* variables. Keep them on the backend, or behind a server-side function, and let the frontend call an endpoint that uses them on its behalf.
3. CORS rules only allow your development origin
Cross-Origin Resource Sharing errors are another classic post-deploy surprise. Picture a frontend served from:
https://myapp.example.com
and a backend served from:
https://api.example.com
These are different origins, because an origin is the combination of scheme, host and port. Browsers enforce the same-origin policy, so for the frontend to read responses from the API, the backend must answer with CORS headers that explicitly permit the frontend's origin.
During development, the backend was probably configured to accept requests from the Vite dev server:
http://localhost:5173
Everything worked, so it is easy to assume CORS is done. After deployment, though, the frontend's origin is:
https://myapp.example.com
To the browser, that is an entirely new origin. If the backend still only lists http://localhost:5173, the deployed frontend gets blocked. For a Spring Boot backend, allowing the production origin on a controller could look like this (the snippet is Java):
@CrossOrigin(origins = "https://myapp.example.com")
@RestController
public class UserController {
// ...
}
Annotating each controller works for small services, but in a larger codebase a single central CORS configuration is easier to maintain and harder to forget when you add endpoints. Whatever the stack, the rule is the same: the allowed origins must match the real production frontend origin exactly, including https and any subdomain. A common, workable pattern is to read the list of allowed origins from the backend's own environment configuration so that development, staging and production each allow the right frontend.
Keep in mind that CORS is a browser mechanism. A request that fails with a CORS error in the browser can succeed from curl or Postman, which is why testing the API outside the browser can give a misleading all-clear.
4. Refreshing a client-side route returns 404
This one tends to send developers digging through their router code when the router is working fine. Suppose the app has a route such as:
/dashboard
You land on the home page, click a link to the dashboard, and it renders perfectly. You can navigate around without issue. Then you hit refresh, and the server responds with:
404 Not Found
The explanation lies in how single-page applications work. When you click a link, React Router updates the URL and renders the matching component entirely in the browser; no request for /dashboard ever reaches the server. When you refresh or open that URL directly, the browser asks the hosting server for /dashboard. If the server only knows about real files, it looks for a resource with that path, finds nothing and returns a 404.
The cure is a fallback rule on the host: any request for a frontend route that does not match a real file should be rewritten or redirected to index.html. The browser then loads the app, and React Router reads the URL and renders the right screen. React Router's deployment documentation describes this requirement, and most static hosts offer a setting or a small config file for it. So when clicking works but refreshing fails, leave your routes alone and look at the host's SPA rewrite configuration first. If you want a refresher on how routes are declared in the first place, see React Router basics.
5. Import paths with the wrong letter case
Some bugs stay invisible until the build runs on a different operating system. Imagine a component file named:
Header.jsx
imported like this:
import Header from "./components/header";
The only difference is one capital letter, which is easy to overlook in review. Filesystems disagree about whether that matters. The defaults on macOS and Windows are case-insensitive, so the import resolves and the app runs. Most build servers and containers run Linux, whose filesystems are case-sensitive, so there Header.jsx and header.jsx are two unrelated names and the build fails with a module-not-found error.
The fix is simply to make every import match the file on disk character for character. Given:
components/Header.jsx
write the import as:
import Header from "./components/Header";
Apply the same care to directory names. One extra trap worth knowing: renaming a file only by case may not be picked up by Git on a case-insensitive filesystem, so the repository can keep the old name even though your editor shows the new one. Checking the name that Git actually tracks resolves the mystery quickly.
6. Images and static assets disappear
Sometimes the app itself loads but its images, fonts or icons do not. That usually means the assets are referenced by paths that happened to work on the dev server but do not exist in the production output.
Vite offers two distinct mechanisms, and it helps to know which one you are using. The first is importing an asset from source code:
import logo from "./assets/logo.png";
and using the imported value as the image source:
function App() {
return <img src={logo} alt="Logo" />;
}
Because the asset goes through the module graph, Vite processes it during the build, copies it into the output with a generated file name and gives you the correct URL. You never have to guess the final path.
The second mechanism is the public directory, for files that should be served as-is without processing:
public/
└── logo.png
Files there are copied to the root of the build output, so you reference them with an absolute URL path:
<img src="/logo.png" alt="Logo" />
The underlying principle is the difference between a path on your disk and a URL the browser can fetch. Your editor may know exactly where src/assets/logo.png lives, but the browser only understands URLs that the deployed server actually serves. If the app is hosted under a sub-path rather than the domain root, absolute paths like /logo.png can also break, which is what Vite's base option exists to handle.
When an asset goes missing, open the browser's Network tab and inspect the failing request. You will usually see either a path that does not exist in the build or a 404 from the server, and that tells you which of the two mechanisms needs correcting.
7. The dev server is not the production build
Running the app with:
npm run dev
starts a development server with hot module replacement, detailed warnings and on-demand transformation of your modules. What gets deployed is something else: a bundled, optimized set of static files. For a Vite project, you produce it with:
npm run build
and your host serves the resulting output. Because the two paths process your code differently, it is entirely possible for an app to run under:
npm run dev
yet fail under:
npm run build
That failure is good news if you catch it yourself, because you can fix it before anything reaches users. Vite also lets you serve the built output locally with:
npm run preview
which is much closer to what visitors will receive than the dev server. Make building and previewing locally a habit before every deploy instead of letting the hosting platform be the first place the production build ever runs. Wiring npm run build into CI gives you the same guarantee automatically on every pull request.
8. Your local node_modules hides missing dependencies
A long-lived node_modules directory can make a project look healthier than it is. Over weeks of development, packages accumulate there: things you installed once to try out, transitive dependencies of other packages, leftovers from a branch you abandoned. Your code may import one of them and work purely because it happens to be present.
A deployment environment starts clean. It installs exactly what package.json and the lockfile describe, nothing more. So if your code contains:
import axios from "axios";
then Axios needs to be declared as a real dependency of the project, for example:
{
"dependencies": {
"axios": "^1.0.0",
"react": "^19.0.0",
"react-router-dom": "^7.0.0"
}
}
The version numbers in that snippet are illustrative; use the ones your project actually needs. The goal is that a fresh clone followed by a fresh install contains everything required to build and run the app. Commit both package.json and the lockfile and keep them in sync, so every install resolves the same versions.
To reproduce the deployment conditions locally, delete node_modules and install again, ideally with npm ci, which installs strictly from the lockfile and fails if the lockfile and package.json disagree. If the build breaks after that, you have found an undeclared dependency before your host did.
9. The frontend is fine and the backend is failing
It is tempting to blame React whenever the screen looks wrong, but the fault is often further down the stack. Suppose the deployed frontend loads successfully at:
https://myapp.example.com
You open the dashboard and it is empty. In the Network tab you find:
GET https://api.example.com/api/dashboard
500 Internal Server Error
That line tells you a lot. The React app did its job and sent the request; the backend received it and then failed while handling it. Likely causes include a broken database connection, a missing server-side environment variable, an authentication problem, an unhandled exception or configuration that differs from your local setup.
A typical full-stack request travels through these layers:
React Frontend
↓
REST API
↓
Backend
↓
Database
A failure at the bottom surfaces at the top. If the database is unreachable, the dashboard looks broken even though every line of frontend code is correct. That is why diagnosis should not end at the browser. When an API call fails, read the Network tab and then read the backend logs. If the browser reports a 500 and the server log says:
Database connection failed
you are dealing with a backend or infrastructure problem, not a rendering bug. A frontend that shows a clear error state for failed requests, instead of an empty page, makes this distinction visible to users and developers alike.
10. Authentication works locally and fails after deploy
Login flows are especially sensitive to environment differences. Locally, the sequence might be:
localhost → Login → Dashboard
In production, it becomes:
Production → Login → Dashboard doesn't load
Several earlier problems can cause this on their own. The frontend may still call the wrong API URL, the backend's CORS rules may not include the production origin, or tokens may not be configured correctly for the new environment. Cookies, though, are the most frequent culprit.
Why cookies behave differently in production
Whether a browser stores and sends a cookie depends on its attributes: Secure, HttpOnly, SameSite, Domain and Path. Locally everything often shares one host, so those attributes rarely matter. In production, the frontend and API may sit on different domains, over HTTPS, and the browser starts enforcing the rules. For example, a cookie marked SameSite=None is only accepted when it is also marked Secure.
Cross-origin requests that carry credentials need cooperation from both sides as well. The client has to opt in to sending credentials, and the server has to explicitly allow credentials for that specific origin; a wildcard origin is not accepted for credentialed requests. If either side is missing its part, the login request may appear to succeed while the session cookie never arrives on subsequent calls.
The takeaway: a login flow that works on localhost has only been tested in the easiest possible environment. Test it again against production-like HTTPS, domains, cookie settings and CORS rules.
A layer-by-layer debugging method
When a deployment breaks, the least productive response is editing random components and hoping. The productive response is to find out which layer is failing before touching any code.
Start with the Console
Open the browser's developer tools (usually F12) and read the Console. Messages such as Failed to fetch, CORS violations, uncaught JavaScript exceptions and 404s for scripts or assets each point toward a different layer.
Read the Network tab
Switch to the Network tab and reload the page. For every request you can see the exact URL, the HTTP method, the status code, the response body and often the headers. For API problems, this is the most informative view you have. You might see:
GET /api/users
Status: 404
or:
GET /api/users
Status: 401
or:
GET /api/users
Status: 500
Each status code narrows the search:
404: the requested resource or route does not exist at that URL.401: authentication is required, or the credentials sent are missing or invalid.403: the server understood who you are but refuses to authorize the action.500: the server hit an internal error while processing the request.
Check the URL the browser actually used
Do not trust what you believe the source code does; look at the request the browser really sent. If production is calling:
http://localhost:8080/api/users
while the backend actually lives at:
https://api.example.com/api/users
you have almost certainly found the problem, and it is the API URL issue from section 1.
Follow server errors into the backend logs
A 500 means the answer is on the server. Backend logs can reveal a failed database connection, a missing variable or an exception that the frontend has no way to report. This habit of moving to the next layer when the evidence points there is what makes deployment debugging fast.
Walking through a real failure
Consider a full-stack app that deploys without errors, yet the dashboard renders empty. The instinctive reaction is to assume the dashboard component has a bug:
"Something must be wrong with the dashboard component."
Instead of opening the component, open the Network tab. There you find:
GET https://api.example.com/api/dashboard
Status: 500
That already rules out the frontend as the root cause: the request was sent. Next, the backend logs show:
Database connection failed
With both pieces of evidence, the whole chain becomes clear:
React Frontend
↓
API request
↓
Backend
↓
Database connection failed
↓
500 response
The dashboard component was never at fault. Ten minutes of reading the browser's tools and the server's logs replaced what could have been hours of refactoring working code. Being able to read browser errors, network traffic and server logs together is one of the most valuable skills in day-to-day software work.
A pre-deploy checklist
The most reliable way to reduce deployment surprises is to trace, before shipping, every hop a request makes from browser to database. A practical checklist:
- Run
npm run buildlocally and confirm it succeeds, then check it withnpm run preview. - Confirm the production API URL is set and that no request points at
localhost. - Verify every required environment variable exists on the hosting platform, and that no secret lives in a frontend variable.
- If frontend and backend are on different origins, confirm the backend allows the exact production frontend origin.
- For React Router apps, open important routes directly and refresh them, rather than only clicking through from the home page.
- Check that images, fonts and other static assets load in the deployed build.
- For apps with authentication, test login, logout, protected routes and refreshing an authenticated page.
- Watch the Console and Network tabs after deploying, and read backend logs whenever an API call fails.
No checklist guarantees a flawless release, but this one catches the majority of common problems while they are still cheap to fix. For a broader look at what a solid project setup should include from day one, the production-ready React baseline covers the tooling side.
Why "it works locally" proves so little
Your machine is a controlled, forgiving environment. You know where the backend runs, which variables are defined, what is installed in node_modules and which database the app uses. Production changes almost all of it: a different domain, a different API address, different environment variables, a different database, stricter CORS and cookie rules, a server that must be told how to handle client-side routes, and often a different operating system. The code can be entirely correct while the configuration around it is wrong.
That is why deployment deserves to be treated as another environment you test against, not a final step that happens after development is over. Staging environments, CI builds and preview deployments all exist to move that testing earlier.
Key takeaways
- When something breaks after deploy, ask which layer is failing before asking what is wrong with React.
- If nothing loads, look at the build output and assets; if the page loads but data does not, look at the Network tab and the API URL.
- A
500sends you to the backend logs; a CORS error sends you to the backend's allowed origins. - A route that works on click but 404s on refresh needs an SPA fallback on the host, not router changes.
- Missing values usually mean production environment variables were never set, or the app was not rebuilt after setting them.
- Authentication failures tend to combine API URLs, CORS and cookie attributes, so check all three.
- A deployed app is a system of frontend, API, backend, database, configuration, hosting, network and browser. Debug the layers one at a time and deployment failures stop being mysterious.
Running into these problems is not a sign of weak programming skills; it is how everyone learns what real systems are made of. The goal is not to never see an error, but to get fast at locating where it actually lives.