React Router 20 ⚛️ Production Server — Configuring history fallback rewrites
The previous chapters covered the route configuration, the data loaders, the actions, and the testing. This chapter covers the deployment: the configuration that makes the single-page application (SPA) work on the production server. The React Router uses the HTML5 history API for the client-side navigation. The createBrowserRouter changes the URL without the full page reload. The user navigates to /dashboard/settings, and the client renders the settings page. The problem: when the user refreshes the browser on /dashboard/settings, the browser sends a request to the server for /dashboard/settings. The server does not have that route as a file. The server returns the 404. The history fallback rewrite is the configuration that tells the server: “if the request is not for a static file, serve the index.html, and let the React Router handle the URL.”
Key point: The history fallback rewrite is the server configuration that serves the index.html for every request that is not a static file. The React Router reads the window.location and renders the matching route. The Nginx uses the try_files $uri $uri/ /index.html;. The Apache uses the FallbackResource /index.html or the mod_rewrite. The Vite preview has the built-in fallback. The serverless platforms (the Netlify, the Vercel) require the _redirects or the vercel.json. The configuration without the fallback works on the first load and fails on the refresh.
Why the history fallback requires a specific mental model
The static file versus route problem. The server does not know the React Router’s routes. The server knows the files on the disk. The /index.html is the file. The /assets/main.js is the file. The /dashboard/settings is not the file; it is the React Router’s route. The server must distinguish the static file request from the route request. The try_files checks the file existence; if the file does not exist, it serves the index.html.
The browser refresh problem. The user navigates to the route. The React Router updates the URL without the server request. The user refreshes. The browser sends the GET request to the server for the current URL. The server must return the index.html. The client loads the React app, the router reads the URL, and the route renders.
The deep link problem. The user shares the URL /posts/42. The recipient opens the link. The browser sends the request to the server. The server returns the index.html. The client renders the post. Without the fallback, the recipient sees the 404.
The static asset problem. The try_files $uri $uri/ /index.html; serves the file if it exists. The /assets/main.js exists and is served. The /dashboard/settings does not exist and falls back to the index.html. The $uri/ handles the directory index. The order matters.
The API problem. The server serves both the SPA and the API. The /api/* must not fall back to the index.html; it must go to the API backend. The location /api { proxy_pass ... } is the separate block that takes precedence over the location /.
The cache problem. The index.html must not be cached aggressively. The assets with the content hash (the main.abc123.js) can be cached for the year. The index.html must be revalidated on every request. The Cache-Control: no-cache on the HTML, the Cache-Control: max-age=31536000, immutable on the hashed assets.
The trade-off. The fallback is the mandatory for the SPA. The misconfigured fallback serves the index.html for the missing assets, which causes the MIME type errors and the broken styles. The try_files with the specific extension check is the safer.
a. The Nginx configuration
The Nginx uses the try_files in the location / block.
server {
listen 80;
server_name example.com;
root /var/www/myapp/dist;
# Serve the static assets and fall back to the index
location / {
try_files $uri $uri/ /index.html;
}
# Cache the hashed assets
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# The API must not fall back
location /api/ {
proxy_pass http://127.0.0.1:3000;
}
}
The try_files $uri $uri/ /index.html; checks the file, the directory, and the fallback. The /assets/ block sets the long cache for the hashed files. The /api/ block proxies to the backend, not the index.html.
The try_files order: the $uri (the exact file), the $uri/ (the directory index), the /index.html (the fallback). If the $uri is the /assets/main.js, the file is served. If the $uri is the /dashboard/settings, the file does not exist, and the fallback serves the index.html.
b. The Apache configuration
The Apache uses the FallbackResource or the mod_rewrite.
<VirtualHost *:80>
ServerName example.com
DocumentRoot /var/www/myapp/dist
<Directory /var/www/myapp/dist>
Options -MultiViews
AllowOverride None
Require all granted
FallbackResource /index.html
</Directory>
# Exclude the API from the fallback
<Location /api>
ProxyPass http://127.0.0.1:3000/api
ProxyPassReverse http://127.0.0.1:3000/api
</Location>
</VirtualHost>
The FallbackResource /index.html serves the index.html for the requests that do not match the file. The Options -MultiViews prevents the Apache from the content negotiation, which can interfere with the fallback. The AllowOverride None prevents the .htaccess from the overriding the config.
The mod_rewrite alternative.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /
RewriteRule ^index\.html$ - [L]
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule . /index.html [L]
</IfModule>
The RewriteCond %{REQUEST_FILENAME} !-f and the !-d are the “if the file and the directory do not exist”. The RewriteRule . /index.html [L] is the fallback. The RewriteRule ^index\.html$ - [L] prevents the infinite loop.
c. The Vite, the Netlify, and the Vercel
The Vite preview has the built-in fallback. The vite preview serves the index.html for the unknown routes.
# Vite preview with the fallback
npx vite preview --port 4173
The Vite dev server also has the fallback. The appType: 'spa' in the vite.config.js is the default. The appType: 'mpa' disables the fallback.
The Netlify uses the _redirects file.
# public/_redirects
/* /index.html 200
The /* is the catch-all. The /index.html is the target. The 200 is the status. The file must be in the public/ directory (the Vite copies it to the dist/).
The Vercel uses the vercel.json or the framework preset.
{
"rewrites": [
{ "source": "/(.*)", "destination": "/index.html" }
]
}
The rewrites catch-all to the index.html. The API routes must be excluded with the routes block or the specific rewrite order.
The vercel.json with the API exclusion.
{
"rewrites": [
{ "source": "/api/(.*)", "destination": "/api/$1" },
{ "source": "/(.*)", "destination": "/index.html" }
]
}
The API rewrite is the first, the fallback is the the last. The order matters.
Complete Example Session
# ============================================
# PART 1: BUILD THE APP
# ============================================
npm run build
# Output: dist/index.html, dist/assets/main-abc.js
# ============================================
# PART 2: NGINX CONFIG
# ============================================
server {
root /var/www/myapp/dist;
location / { try_files $uri $uri/ /index.html; }
}
# ============================================
# PART 3: NGINX RELOAD
# ============================================
sudo nginx -t
sudo systemctl reload nginx
# ============================================
# PART 4: TEST THE ROOT
# ============================================
curl http://localhost/
# The index.html
# ============================================
# PART 5: TEST THE DEEP LINK
# ============================================
curl http://localhost/dashboard/settings
# The index.html (not the 404)
# ============================================
# PART 6: TEST THE ASSET
# ============================================
curl http://localhost/assets/main-abc.js
# The JS file
# ============================================
# PART 7: TEST THE API
# ============================================
curl http://localhost/api/users
# The API response (not the index.html)
# ============================================
# PART 8: THE NETLIFY REDIRECT
# ============================================
echo "/* /index.html 200" > public/_redirects
# ============================================
# PART 9: VERIFY
# ============================================
# http://localhost/dashboard/settings → the SPA renders
# Refresh the URL → the SPA renders (not the 404)
The nine parts trace the production server: the build, the Nginx config, the reload, the root, the deep link, the asset, the API, the Netlify, and the verification.
Quick Reference
Server Configurations
| Server | Fallback |
|---|---|
| Nginx | try_files $uri $uri/ /index.html; |
| Apache | FallbackResource /index.html |
| Vite preview | Built-in |
| Netlify | /* /index.html 200 in _redirects |
| Vercel | rewrites in vercel.json |
Nginx Order
| Order | Purpose |
|---|---|
| 1 | $uri (exact file) |
| 2 | $uri/ (directory) |
| 3 | /index.html (fallback) |
Apache Order
| Order | Purpose |
|---|---|
| 1 | RewriteCond !-f |
| 2 | RewriteCond !-d |
| 3 | RewriteRule /index.html |
Exclusions
| Path | Behavior |
|---|---|
/api/* | Proxy to backend |
/assets/* | Serve the file |
| Other | Serve the index.html |
Best Practices
✅ Do This:
try_files $uri $uri/ /index.html;
<!-- Use the try_files for the fallback --> # ✅
location /api/ { proxy_pass http://127.0.0.1:3000; }
<!-- Exclude the API from the fallback --> # ✅
location /assets/ { expires 1y; add_header Cache-Control "public, immutable"; }
<!-- Cache the hashed assets --> # ✅
❌ Don’t Do This:
# Serve the index.html for every request
<!-- The missing assets return the HTML --> # ❌
# Forget the API exclusion
<!-- The API returns the index.html --> # ❌
# Cache the index.html with the long max-age
<!-- The user sees the stale app --> # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| 404 on refresh | No fallback | Add try_files |
| Missing assets return HTML | Fallback for every request | Check the $uri order |
| API returns HTML | No exclusion | Separate location |
| Stale app after the deploy | Cached index.html | no-cache on HTML |
| MIME type error | Fallback for the main.js | try_files order |
Real-World Examples
1. Nginx
location / { try_files $uri $uri/ /index.html; }
2. Apache
FallbackResource /index.html
3. Vite
vite preview
4. Netlify
/* /index.html 200
5. Vercel
{ "rewrites": [{ "source": "/(.*)", "destination": "/index.html" }] }
6. API Exclusion
location /api/ { proxy_pass http://backend; }
7. Asset Cache
location /assets/ { expires 1y; }
8. HTML No-Cache
location = /index.html { add_header Cache-Control "no-cache"; }
9. Test
curl http://localhost/dashboard
10. Verify
Refresh the deep link → the SPA renders
Visual
Without the Fallback
┌──────────────────────────────────────────────┐
│ User navigates to /dashboard/settings │
└──────────────────┬───────────────────────────┘
│
│ React Router renders the page
│ (no server request)
▼
┌──────────────────────────────────────────────┐
│ User refreshes (F5) │
└──────────────────┬───────────────────────────┘
│
│ GET /dashboard/settings
▼
┌──────────────────────────────────────────────┐
│ Server checks the file │
│ → The file does not exist │
│ → The 404 │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ User sees the 404 │
└──────────────────────────────────────────────┘
With the Fallback
┌──────────────────────────────────────────────┐
│ User refreshes (F5) │
└──────────────────┬───────────────────────────┘
│
│ GET /dashboard/settings
▼
┌──────────────────────────────────────────────┐
│ try_files checks the file │
│ → The file does not exist │
│ → The /index.html │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ The React app loads │
│ → The router reads the URL │
│ → Renders the settings │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Fallback purpose | Serve the index.html for routes |
| Nginx | try_files $uri $uri/ /index.html |
| Apache | FallbackResource /index.html |
| Netlify | /* /index.html 200 |
| Vercel | rewrites in vercel.json |
| API exclusion | Separate location |
| Asset cache | expires 1y on the /assets/ |
| HTML cache | no-cache on the index.html |
Key takeaways:
- The history fallback is the mandatory. The SPA route is not the server file. The server must serve the
index.htmlfor the unknown routes. The refresh and the deep link fail without the fallback. - The
try_filesis the Nginx mechanism. The$uri(the file), the$uri/(the directory), the/index.html(the fallback). The order is the file first, the fallback last. - The API must be excluded. The
location /api/proxy takes precedence. The API returns the JSON, not theindex.html. The fallback is the SPA only. - The assets are cached, the HTML is not. The hashed assets (the
main.abc.js) have the long cache. Theindex.htmlhas theno-cache. The new deploy invalidates the HTML but reuses the old assets. - The platform-specific configuration exists. The Vite preview, the Netlify
_redirects, the Vercelrewrites. The concept is the same; the syntax is the different.
Remember: The fallback is the server. The try_files is the Nginx. The FallbackResource is the Apache. The /* /index.html 200 is the Netlify. The refresh is the test. The exam tests the fallback pattern.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!