Everything works while you click. You move from the dashboard to the settings page, the URL in the address bar changes to /settings, the right screen appears, and it feels like a normal website.
Then you press refresh on that page and get a 404. Or you send the link to a colleague and it fails for them. Or a user opens a bookmark and lands on your hosting provider's error page instead of your app.
Nothing in your routing code is wrong. The route exists, the component is correct, and the router is configured properly. The problem is that on a refresh, your router is not the thing answering the question. It has not even loaded yet.
Why does the same URL work when I click but not when I reload?#
These are two completely different journeys, and the difference is the whole article.
When you click a link inside a single page application, no request goes out. Your Javascript intercepts the click, calls the History API to change what the address bar displays, and swaps the component on screen. The URL changes, but the browser never asks anyone for anything. The page you were already on simply redraws itself.
When you press refresh, the browser throws all of that away and does what it always does with a URL, which is ask the server for it. It sends a real GET /settings over the network, and now your application is not involved at all, because it no longer exists. It was wiped from memory the moment you hit reload.
So the server receives a request for /settings, looks in the folder it is serving, and finds index.html, a assets directory and nothing else. There is no settings file and no settings folder, because your build never created one. It responds with a 404, correctly, because from its point of view that path genuinely does not exist.
The route only ever existed inside Javascript that never got a chance to run.
Why is this not a bug in React Router?#
Because client side routing is, by design, a fiction that the browser maintains on your behalf.
pushState lets Javascript change the address bar without making a request. That is what makes an SPA feel fast, and it is the entire trick behind modern frontend frameworks. But the address bar is only a display. Changing it does not create anything on the server, does not register a path anywhere, and does not survive a reload.
Your router can only handle a URL if it is already running. On a refresh or a direct visit, the order is reversed: the request happens first, and your router loads afterwards, if the server sends it anything at all. Every framework has this problem, whether it is React Router, Vue Router, Angular, or a router you wrote yourself in an afternoon.
How do you actually fix it?#
There is one rule, and every fix below is the same rule written in a different configuration language:
When a request does not match a real file, send index.html anyway, with a 200 status.
That way, a request for /settings returns your application. The browser loads it, your router starts up, reads /settings from the address bar, and renders the right screen. The server does not need to know your routes. It just needs to hand over the app and let the app sort it out.
For Nginx:
location / {
try_files $uri $uri/ /index.html;
}That reads as "try the file, then the directory, then fall back to index.html".
For Apache, in .htaccess:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]For an Express server hosting a build:
app.use(express.static("dist"));
app.get("*", (req, res) => {
res.sendFile(path.join(__dirname, "dist", "index.html"));
});The order matters here. The static middleware has to come first, otherwise the catch all swallows requests for your Javascript and CSS as well, and you will get an application that loads index.html for every asset it asks for.
Most hosting platforms have this built in or one line away. Netlify uses a _redirects file with /* /index.html 200. Vercel and Cloudflare Pages detect common frameworks and handle it automatically. On S3 with CloudFront, the usual approach is to set the error document to index.html, though it is worth noting that this historically returned a 403 or 404 status underneath, which search engines do notice.
Why does it work in development but break in production?#
Because your development server already does this for you, quietly.
Vite, webpack dev server and the Next.js dev server all have history API fallback turned on by default. They serve index.html for anything that does not match a file, precisely so that refreshing during development does not annoy you. Then you build, upload the dist folder to a plain static host, and the fallback disappears with it.
This is why the bug so reliably appears at the worst possible moment. It is not introduced by the build. It was hidden by the dev server the entire time you were working.
Why does the page load but every asset 404?#
This is the neighbouring bug, and people often meet it right after fixing the first one.
If you deploy your app into a subdirectory, such as example.com/app/, your index.html will ask for /assets/main.js starting from the domain root, and the server will not find it there. You get a blank white page, a console full of 404s, and often a MIME type error complaining that your Javascript was served as HTML. That error is the fallback doing its job, by the way. It returned index.html for a missing script, exactly as instructed.
The fix is to tell your build where it will live. In Vite that is the base option, in webpack it is publicPath, and in Next.js it is basePath. Set it to /app/ and the generated paths line up with reality.
What about pages that really are missing?#
Once every path returns index.html with a 200, your server can no longer produce a genuine 404. That is a real cost, and it is worth handling deliberately rather than ignoring.
Your router should have a catch all route that renders a proper "not found" screen, so a typo in the URL shows something sensible instead of a blank layout.
For search engines the situation is more awkward. A crawler asking for a mistyped or deleted URL receives a 200 and a page, which tells it the URL is valid. Enough of these and you have what is usually called a soft 404, where search engines index URLs that were never meant to exist. If those pages matter to you, the answer is server side rendering or prerendering for real routes, so that a missing one can return an honest 404 status. A client side catch all screen fixes the experience for people, but it cannot fix the status code, because by the time your Javascript decides the page is missing, the response has already been sent.
A checklist to work through#
- Does refreshing on the home page work, but refreshing on a nested route fail? Then it is this, and the fix is in your host configuration, not your router.
- Have you added a fallback that serves
index.htmlfor unmatched paths? - Does the fallback return
200, rather than a404page that happens to contain your app? - Is static file handling registered before the catch all, so assets are still served normally?
- Are you deploying into a subdirectory? If yes, set
base,publicPathorbasePathto match. - Does your router have a catch all route for genuinely unknown URLs?
- Does it work in
npm run devbut not on the deployed build? That is the dev server's fallback hiding the problem.
Frequently asked questions#
Why does the home page refresh fine but nothing else?#
Because / maps to a file that genuinely exists. Your server finds index.html and serves it, so the app loads and the router takes over. Every other route has no matching file, so it fails at the server before your Javascript is ever involved.
Is hash routing a valid fix?#
It works, and it needs no server configuration at all, because everything after the # is never sent to the server. Your URLs become example.com/#/settings, and the server only ever sees a request for /. The tradeoff is ugly URLs and weaker SEO, since the fragment is not part of what gets requested. It is a reasonable choice for an internal tool or an app behind a login, and a poor one for anything you want indexed.
Does this affect server side rendered apps?#
No, and that is one of the reasons frameworks moved back towards rendering on the server. With Next.js, Nuxt or SvelteKit in their default modes, the server knows your routes and can answer a request for /settings with real HTML, including a real 404 when the page does not exist. You only meet this problem when you export a purely static build and hand it to a server that knows nothing about your routing.
Why do I get a MIME type error about my Javascript?#
Almost always because the fallback returned index.html for a missing asset. The browser asked for a script, received HTML, and refused to execute it. Fix the asset path rather than the fallback, since the fallback is behaving correctly.
Should the fallback return 200 or 404?#
200, for any route your application actually handles. A 404 status tells the browser and search engines the page does not exist, even if you send your app alongside it. Reserve genuine 404 responses for URLs that really are gone, which in practice means rendering on the server.
