404 on page refresh in a React or Vite SPA
THE SHORT ANSWER
A single-page app returns 404 on refresh because the browser asks the server for a path like /dashboard, and the server has no file at that path. Your router only exists in the JavaScript that index.html loads, so the server has to answer every unknown path with index.html and let the router take over. It works locally because Vite's dev server and vite preview already do that. In production you add one rewrite rule to your host: send every path that isn't a real file to /index.html.
Why does refreshing a page give a 404?
Clicking a link inside the app never asks the server for anything. React Router, or whichever router you use, changes the URL with the browser's History API and renders the new page in JavaScript. Refreshing the page, or opening /dashboard from a bookmark or a shared link, is different: the browser sends GET /dashboard to the server.
After vite build, the server has index.html, an assets/ folder and nothing called dashboard. A plain static server answers 404 before your JavaScript ever loads. The fix is to tell the server: if no file matches the path, serve index.html with status 200, and the router reads the URL and renders the right page.
It works on your machine because Vite's appType defaults to 'spa', which turns on that fallback in the dev server and in vite preview. Vite's docs also say vite preview is for checking the build locally and is not meant as a production server, so the fallback has to come from your host.
How do I fix it on my host?
Every host needs the same rule, written in its own config. Real files keep being served as they are:
| Host or server | Where the rule goes | Rule |
|---|---|---|
| Netlify | _redirects in the publish folder (put it in public/) | /* /index.html 200 |
| Vercel | vercel.json at the project root | A rewrite from /(.*) to /index.html |
| Cloudflare Workers | wrangler.toml, under [assets] | not_found_handling = "single-page-application" |
| Cloudflare Pages | Nothing to add | On by default when the build has no top-level 404.html |
| Firebase Hosting | firebase.json | A rewrite from ** to /index.html |
| Nginx | The site's location / block | try_files $uri $uri/ /index.html; |
| Apache | Virtual host or .htaccess | FallbackResource /index.html |
| Caddy | The site block in the Caddyfile | try_files {path} /index.html before file_server |
| Express | After express.static and your API routes | A catch-all route that sends index.html |
The full config for the ones that take more than a line:
// vercel.json (Vercel's Vite docs: deep linking "won't work out of the box" without it)
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
# netlify.toml, if you prefer it to _redirects
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
# wrangler.toml (Cloudflare Workers static assets)
[assets]
directory = "./dist/"
not_found_handling = "single-page-application"# Nginx
location / {
root /var/www/app/dist;
try_files $uri $uri/ /index.html;
}
# Apache (mod_dir)
FallbackResource /index.html
# Caddy
example.com {
root /srv
encode
try_files {path} /index.html
file_server
}Serving the build from Express?
Serve the static files first, then your API, then the catch-all last, so it only answers paths nothing else matched. Express 5 changed the wildcard syntax: a bare * no longer works and the wildcard needs a name. /{*splat} also matches the root path:
import express from 'express'
import path from 'node:path'
const app = express()
const dist = path.resolve('dist')
app.use(express.static(dist))
app.use('/api', apiRouter)
// Express 5. On Express 4 the same route is app.get('*', ...)
app.get('/{*splat}', (req, res) => {
res.sendFile(path.join(dist, 'index.html'))
})
app.listen(process.env.PORT || 3000, '0.0.0.0')If app.get('*') throws a path-to-regexp error after you upgrade, this is the change that caused it.
Refresh now loads a blank page or a MIME type error?
Once the fallback works, a deep URL like /settings/profile can load an empty page, with this in the browser console:
Failed to load module script: Expected a JavaScript module script but the server responded with a MIME type of "text/html".
The page asked for its JavaScript at the wrong path, and the fallback answered with index.html instead of a 404. This happens when Vite's base is ./ or empty. Vite documents those values for embedded deployments: asset links become relative, so on /settings/profile the browser looks for /settings/assets/index.js.
Leave base at its default of / for an app at the root of its domain. For an app under a sub-path, set it to that path, such as /app/, and give your router the same base path.
Doesn't the fallback hide real 404s?
Yes. With the rule in place, /no-such-page also returns index.html with status 200, so it is now the app's job to say a page doesn't exist. Add a catch-all route to your router that renders a Not found page, and keep two things out of the rewrite:
- Your API. A typo in a fetch URL should get a JSON 404, not a page of HTML that fails to parse. Put the API under a prefix like
/apiand route it before the fallback. Caddy's pattern wraps the two in separatehandleblocks. - Missing assets. An old tab asking for a JavaScript file from a previous deploy gets HTML back, which is the MIME type error above. Rules that check for a real file first (
try_files,FallbackResource) still serve every file that exists.
Can I use HashRouter instead?
HashRouter keeps the route after a #, as in /#/dashboard. React Router's docs describe it as storing the location in the hash “so it is not sent to the server”, so every request is for / and no rewrite is needed. It works on hosts where you can't add one.
The cost is URLs with #/ in them and links that are harder to share cleanly. If your host lets you add a rewrite, use BrowserRouter and the rule above.
GitHub Pages is the common case with no rewrite setting. The usual workaround is to copy index.html to 404.html in the build, since GitHub Pages serves a custom 404.html for pages that don't exist. GitHub's docs describe it as an error page, so treat it as a workaround, not a rewrite.
What happens on EthioDeploy?
A Vite app with no server of its own is served by Caddy with the fallback already on, so refreshing /dashboard works with no config file. Files that exist, like everything under /assets, are served as they are.
If your project runs its own server, such as Express serving dist/, the server is yours and needs the catch-all route from the Express section. Bind it to 0.0.0.0 and the PORT variable, as in 502 Bad Gateway after deploy.
Questions
Vite's dev server and vite preview fall back to index.html for any path, because appType defaults to 'spa'. Most production hosts don't, so a refresh on /dashboard asks the server for a file that doesn't exist. Add a rewrite that sends every path that isn't a file to /index.html.
Add a vercel.json at the project root with a rewrite from /(.*) to /index.html. Vercel's Vite docs say deep linking in a Vite SPA won't work out of the box without it.
Add a file named _redirects to the publish folder (public/ in a Vite project, so it is copied into dist/) with the line /* /index.html 200. The 200 makes it a rewrite, not a redirect.
In the location / block, add try_files $uri $uri/ /index.html; so Nginx serves the file if it exists, then the directory, and otherwise index.html.
Express 5 requires wildcards to have a name. Use app.get('/*splat', ...), or app.get('/{*splat}', ...) to match the root path too.
Only if you can't add a rewrite on your host. HashRouter keeps the route after #, which isn't sent to the server, so it works anywhere, but every URL gets #/ in it.
Sources
Checked on November 4, 2026:
- Vite: appType
- Vite: base
- Vite: Deploying a static site
- React Router: Direct all URLs to index.html
- React Router: HashRouter
- Netlify: History pushState and single-page apps
- Vercel: Using Vite to make SPAs
- Cloudflare Workers: Single page application
- Cloudflare Pages: SPA rendering
- Nginx: try_files
- Apache: FallbackResource
- Caddy: Single-page apps
- Express: Migrating to Express 5 (path syntax)
- GitHub Pages: Custom 404 page
Push a repo. Get a URL.
Start freeJoin the EthioDeploy Telegram
Release notes as they ship, plus deploy help from the team and other developers.