How to Fix Cannot GET Errors After Deploying an SPA
Fix Cannot GET and SPA 404 errors after deployment with server rewrites for Nginx, Apache, Netlify, Vercel, and S3 CloudFront.
A “Cannot GET /route” or 404 error after deploying a single-page app is usually a server configuration problem rather than a routing bug. The fix is to make the server return index.html for any request path that does not match a real file.
The pattern is familiar. The build ships, every page works when you click through it, and then someone refreshes /dashboard, or opens a shared link to /orders/42, and gets a bare 404. Usually the router is fine and so is the build. The server was asked for a file that does not exist.
This article explains why the error only appears on hard navigations, then covers the fix and the config for Nginx, Apache, Netlify, Vercel, and S3 behind CloudFront, along with the side effect to handle afterward.
Key Takeaways
- An SPA 404 on refresh happens because the request reaches the server, which looks for a real file at that path and finds only
index.htmlat the root. - Local dev servers hide the bug because they already fall back to
index.htmlfor unmatched paths. - The fix is a rewrite, not a redirect: serve
index.htmlwith a 200 status so the URL stays intact for the router to read. - On S3, the error-document approach keeps the 404 status; a CloudFront custom error response mapping both 403 and 404 to
/index.htmlis what returns a 200. - A catch-all fallback means bad URLs return 200, so the app needs its own wildcard route rendering a not-found view.
When Does the Cannot GET Error Appear?
The error shows up only on hard navigations: a page refresh, a URL typed into the address bar, or a shared deep link opened in a fresh tab. In-app navigation keeps working, because once the app has loaded, the router changes views entirely in the browser without contacting the server. The exact message varies by host: Express-based servers print “Cannot GET /route”, while static hosts return their 404 page.
This is also why the bug survives QA. Session replays of a freshly deployed SPA show the break at hard navigations, a refresh or an externally opened link, never during in-app clicking, so testing that only clicks through the running app passes cleanly while real users hit the 404.
Why Is an SPA 404 on Refresh a Server Problem?
A static server maps each request path to a file on disk. An SPA build produces one HTML file, index.html, plus JS and CSS assets, so a direct request for /dashboard finds no file at that path and the server correctly answers 404. React Router, Vue Router, and SvelteKit configured as a single-page app all hit this identically, because the framework is irrelevant: the routes exist only in JavaScript that has not loaded yet.
The error never appears in local development because most SPA dev servers ship with the fallback already enabled: any path that does not match a file is served index.html automatically. Your local setup was quietly doing the thing your production server is not.
What Is the Fix for a Cannot GET Error?
Configure the server to serve index.html for any request path that does not match an existing file, so the app loads and its router renders the view for that URL. This must be a rewrite returning index.html with a 200 status, not a redirect: a redirect would change the URL in the address bar, and the router needs the original path intact.
| Host | Where the config lives | Mechanism |
|---|---|---|
| Nginx | server block | try_files |
| Apache | vhost or .htaccess | FallbackResource |
| Netlify | _redirects or netlify.toml | 200-status rewrite rule |
| Vercel | vercel.json | rewrites array |
| S3 + CloudFront | bucket website config + distribution | error document + custom error response |
If you truly cannot touch the server, hash-based routing sidesteps all of this because the fragment never leaves the browser, but it permanently turns every URL into /#/about, so treat it as a last resort.
Nginx and Apache
Nginx and Apache each express the SPA fallback as a single directive in the server config. For Nginx, add a try_files fallback to the root location. It looks for the request path as a file, then as a directory, and when neither turns up it serves /index.html internally with a 200:
server {
listen 80;
root /var/www/app/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
For Apache, one directive from mod_dir does the same job. Real files still go out as themselves, and everything else falls through to the fallback:
FallbackResource /index.html
If the app lives under a sub-path, include it: FallbackResource /app/index.html. The older mod_rewrite equivalent still works in .htaccess:
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ /index.html [L]
Netlify and Vercel
On Netlify, a redirect rule with a status of 200 becomes a rewrite: the browser keeps showing the path the visitor asked for, and the contents of index.html come back in the response. Either add a one-line _redirects file:
/* /index.html 200
or the equivalent in netlify.toml:
[[redirects]]
from = "/*"
to = "/index.html"
status = 200
The _redirects file must land inside the publish directory, so make sure your build copies it into the output folder; netlify.toml lives at the repo root. A splat rule will not take over a path that has a real file behind it, so JS and CSS assets keep loading.
For Vercel, add a rewrites entry to vercel.json:
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
Prefer the explicit /index.html destination over /: both resolve to the same file on Vercel, but the explicit form states what is actually served and transfers as a mental model to every other host. One exception: with cleanUrls: true set, the destination cannot carry the .html extension, and Vercel maps index.html to the site root, so set the destination to /.
S3 and CloudFront
Fixing an SPA 404 on S3 takes two pieces of configuration, because the bucket setting alone preserves the error status. On S3 static website hosting, set index.html as both the index document and the error document:
aws s3 website s3://your-bucket \
--index-document index.html \
--error-document index.html
This serves the app for unknown paths but preserves the error status: the browser receives index.html with a 404 code. To return a 200, add CloudFront custom error responses mapping both 403 and 404 to /index.html with a 200 response code. The 403 mapping matters because a distribution using the S3 REST endpoint as origin receives 403 Access Denied, not 404, for keys that do not exist. In Terraform terms:
custom_error_response {
error_code = 403
response_code = 200
response_page_path = "/index.html"
}
custom_error_response {
error_code = 404
response_code = 200
response_page_path = "/index.html"
}
One footgun: custom error responses apply distribution-wide, so if you proxy /api/* through the same distribution, API 403s and 404s also come back as index.html.
The Cost: Your Real 404s Disappear
A catch-all fallback has one price: genuinely wrong URLs now return index.html with a 200 instead of a real 404. The server can no longer distinguish /orders/42 from /ordersss/42, so the app must define its own wildcard route rendering a not-found view. Every router has a spelling for this; in React Router it looks like:
<Route path="*" element={<NotFound />} />
Note that this is a client-rendered 404: the HTTP status is still 200, which matters if you care how crawlers classify those pages.
Wrapping Up
The 404 on refresh is the server doing exactly what static servers do, and the fix is one rule applied in your host’s dialect: rewrite every non-file path to index.html with a 200. Add the snippet for your host, redeploy, hard-refresh a deep route to confirm, then add the wildcard not-found route so bad URLs still tell users they are lost.
FAQs
Does the SPA fallback fix work on GitHub Pages?
No. GitHub Pages does not support server-side rewrites, so there is no way to configure an index.html fallback rule. The standard workaround is a custom 404.html page containing a script that redirects to index.html while preserving the requested path, which the router then restores after load. GitHub still serves that page with a 404 status. The other option is hash-based routing, which never sends the route to the server.
Do server-rendered frameworks like Next.js or Nuxt have this problem?
Not when they run their own server. A server-rendered framework handles every route on the server, so a refresh or deep link returns rendered HTML directly. The 404-on-refresh problem only affects static single-page builds where routes exist solely in client-side JavaScript. A statically exported app from one of these frameworks can still hit it when a requested route has no pre-rendered HTML file on disk.
Will rewriting every path to index.html break my JS and CSS assets?
No. Each mechanism looks for a real file before falling back: Nginx try_files tries the request URI first, Apache FallbackResource leaves requests for real files alone, and a Netlify splat rewrite will not take over an existing path unless you force it with 200!. If assets still fail after adding the fallback, the usual cause is relative asset paths resolving under a nested route, so the browser requests them from the wrong directory and receives index.html instead.
Does serving index.html with a 200 status hurt SEO?
It can. When a nonexistent URL returns a 200 status with not-found content, Google may classify it as a 'soft 404' and drop it from the index, because the status code no longer distinguishes real pages from bad URLs. If search indexing matters for your routes, pre-rendering or server-side rendering restores correct per-route status codes. For apps behind a login, crawlers never see the routes, so the tradeoff is irrelevant.