Clicking works, refreshing 404s
Every link inside the app works. Then somebody refreshes on /dashboard, or
opens a link you sent them, and gets 404 — page not found. The page
exists — they were just looking at it.
Why this happens to every single-page app
A single-page app is one HTML file. React Router (or any client router) changes the URL and swaps what is on screen — inside the already-loaded page. The server is not involved, which is why clicking works.
Refreshing — or opening a deep link, or a search engine visiting — asks the
server for /dashboard. The server looks for a file called that, finds
none (there is only index.html), and answers 404. Both halves are behaving
correctly; they have never been introduced.
The fix is one rule on the server: for any URL that matches no real file,
serve index.html — the app then boots and its router shows the right page.
The rule, host by host
Netlify — a file named _redirects in the folder that gets published
(for Vite: public/, so it lands in dist/):
/* /index.html 200
The 200 matters: it makes this a rewrite, not a redirect — the URL stays
/dashboard.
Vercel — vercel.json at the project root:
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }
(For frameworks Vercel detects, it often configures this itself — add the file when it has not.)
Azure Static Web Apps — staticwebapp.config.json:
{ "navigationFallback": { "rewrite": "/index.html", "exclude": ["/assets/*"] } }
The exclude list is worth reading twice: anything not excluded falls back
to the app's HTML — including, if you are not careful, files that should be
served as themselves, like robots.txt. (This site runs on Static Web Apps;
that exclude list is not hypothetical.)
GitHub Pages — there is no rewrite support. The honest options: use a
HashRouter (URLs become /#/dashboard, no server lookup involved), or the
404.html trick — a copy of index.html served on any miss. If the site
lives under /repo-name/, that is a second problem — base
paths.
nginx — in the site's location /:
try_files $uri $uri/ /index.html;
Two edges that bite later
- Keep the API out of the fallback. If
/api/*rewrites toindex.html, a missing endpoint answers with your app's HTML and a 200 — and the frontend then fails parsing HTML as JSON, one step removed from the real error. Exclude API paths so they can 404 honestly. - The 404 must stay a 404 where it is true. With the fallback in place, a genuinely wrong URL also serves the app; make sure the router shows a real not-found page, or every typo becomes a silently blank screen.
Hand it to the AI
Paste this to the AI that built your app
My deployed single-page app 404s on refresh and on deep links. Host: [Netlify / Vercel / Azure Static Web Apps / GitHub Pages / nginx]. The build output folder is [dist/build]. Add the SPA fallback configuration for this host so unknown routes serve index.html with status 200, while real files and /api/* are excluded from the fallback. Then confirm the router renders a proper not-found page for genuinely wrong URLs.
Prove it
Deploy, then: refresh on a deep route (stays on the page, no 404), open the same route in a private window from a pasted link (same), and request a URL that truly does not exist (the app's own not-found page, not a blank screen). Three checks, ten seconds each.
Questions people also ask
Why does clicking to the page work when refreshing it does not?
Clicking never leaves the loaded app — the router changes the URL and renders the page itself, with no server involved. Refreshing asks the server for that URL as a file, and the server has no such file: a single-page app has exactly one HTML file.
Is this a React Router bug?
No. The router is doing its half correctly; the server was never told about the arrangement. The fix is one rewrite rule on the server, not a code change.
Will the rewrite break my API routes or real files?
Configured properly, no: real files are matched first (or explicitly excluded, on hosts that support it), and only URLs with no matching file fall back to index.html. Keep API paths out of the fallback so a missing endpoint still 404s honestly.