404 Not Found in Single Page Applications
Symptoms
- Navigating to `https://myapp.com/dashboard` directly in the browser or hitting refresh returns a 404 from the server
- Clicking links within the app works fine — the 404 only appears on hard reload or direct URL access
- Server access log shows a `GET /dashboard HTTP/1.1" 404` — the server is looking for a real file at that path
- Vercel, Netlify, or Apache serves the raw "404 Not Found" page rather than the app's custom 404 component
- `react-router` or `vue-router` is configured with `createBrowserHistory` / `history: createWebHistory()` (not hash-based routing)
- Clicking links within the app works fine — the 404 only appears on hard reload or direct URL access
- Server access log shows a `GET /dashboard HTTP/1.1" 404` — the server is looking for a real file at that path
- Vercel, Netlify, or Apache serves the raw "404 Not Found" page rather than the app's custom 404 component
- `react-router` or `vue-router` is configured with `createBrowserHistory` / `history: createWebHistory()` (not hash-based routing)
Root Causes
- The web server is not configured to fall back to `index.html` for unknown paths — it looks for a real file at `/dashboard` and finds none
- SPA build output is missing — `dist/index.html` was not committed or uploaded to the hosting provider
- Nginx `try_files` directive is absent or incorrectly ordered, so requests that don't match a static file return 404 immediately
- Apache `mod_rewrite` is not enabled or the `.htaccess` fallback rule is not present in the document root
- Hosting platform (Vercel, Netlify, S3 CloudFront) needs a rewrites or redirect rule that is not yet configured
Diagnosis
**Step 1: Confirm the SPA vs server 404**
```bash
curl -I https://myapp.com/dashboard
# Server 404 → response body is plain HTML, not your app
# App 404 → response is 200, body is index.html, your router handles it
```
**Step 2: Check what the server actually serves**
```bash
curl -s https://myapp.com/dashboard | head -5
# Should be <!DOCTYPE html> from index.html
```
**Step 3: Inspect Nginx/Apache config for try_files**
```bash
sudo nginx -T | grep try_files
# Should contain: try_files $uri $uri/ /index.html;
```
**Step 4: Check hosting platform rewrites config**
```bash
# Vercel: check vercel.json
cat vercel.json | jq .rewrites
# Netlify: check netlify.toml or _redirects file
cat netlify.toml
cat public/_redirects
```
```bash
curl -I https://myapp.com/dashboard
# Server 404 → response body is plain HTML, not your app
# App 404 → response is 200, body is index.html, your router handles it
```
**Step 2: Check what the server actually serves**
```bash
curl -s https://myapp.com/dashboard | head -5
# Should be <!DOCTYPE html> from index.html
```
**Step 3: Inspect Nginx/Apache config for try_files**
```bash
sudo nginx -T | grep try_files
# Should contain: try_files $uri $uri/ /index.html;
```
**Step 4: Check hosting platform rewrites config**
```bash
# Vercel: check vercel.json
cat vercel.json | jq .rewrites
# Netlify: check netlify.toml or _redirects file
cat netlify.toml
cat public/_redirects
```
Resolution
**Fix 1: Nginx — add try_files fallback**
```nginx
server {
listen 80;
root /var/www/myapp/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
```
```bash
sudo nginx -t && sudo systemctl reload nginx
```
**Fix 2: Apache — add .htaccess RewriteRule**
```apache
# public/.htaccess
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
```
**Fix 3: Vercel — add rewrites in vercel.json**
```json
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
```
**Fix 4: Netlify — add _redirects file**
```
# public/_redirects
/* /index.html 200
```
```nginx
server {
listen 80;
root /var/www/myapp/dist;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}
```
```bash
sudo nginx -t && sudo systemctl reload nginx
```
**Fix 2: Apache — add .htaccess RewriteRule**
```apache
# public/.htaccess
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
```
**Fix 3: Vercel — add rewrites in vercel.json**
```json
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
```
**Fix 4: Netlify — add _redirects file**
```
# public/_redirects
/* /index.html 200
```
Prevention
- **Test direct URL access in CI**: Add a smoke test that curls `/some/deep/route` directly after deploy and asserts a 200 status with the `index.html` body
- **Use hash routing for static hosts**: If you can't configure the server (e.g. GitHub Pages), use `createHashHistory` / `createWebHashHistory` — routes become `/#/dashboard` which the server never sees
- **Document the server config**: Keep `nginx.conf` or `.htaccess` in the same repo as your SPA so the fallback rule travels with the code
- **404 vs 200**: Return HTTP 200 for all SPA routes (let the router show a 404 UI) — real 404s should only be for missing static assets
- **Use hash routing for static hosts**: If you can't configure the server (e.g. GitHub Pages), use `createHashHistory` / `createWebHashHistory` — routes become `/#/dashboard` which the server never sees
- **Document the server config**: Keep `nginx.conf` or `.htaccess` in the same repo as your SPA so the fallback rule travels with the code
- **404 vs 200**: Return HTTP 200 for all SPA routes (let the router show a 404 UI) — real 404s should only be for missing static assets