| | |

React Router 20 ⚛️ Production Server — Configuring history fallback rewrites

⏱️ Estimated read: 10 min | |

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

ServerFallback
Nginxtry_files $uri $uri/ /index.html;
ApacheFallbackResource /index.html
Vite previewBuilt-in
Netlify/* /index.html 200 in _redirects
Vercelrewrites in vercel.json

Nginx Order

OrderPurpose
1$uri (exact file)
2$uri/ (directory)
3/index.html (fallback)

Apache Order

OrderPurpose
1RewriteCond !-f
2RewriteCond !-d
3RewriteRule /index.html

Exclusions

PathBehavior
/api/*Proxy to backend
/assets/*Serve the file
OtherServe 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

PitfallWhy It HappensFix
404 on refreshNo fallbackAdd try_files
Missing assets return HTMLFallback for every requestCheck the $uri order
API returns HTMLNo exclusionSeparate location
Stale app after the deployCached index.htmlno-cache on HTML
MIME type errorFallback for the main.jstry_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

ItemValue
Fallback purposeServe the index.html for routes
Nginxtry_files $uri $uri/ /index.html
ApacheFallbackResource /index.html
Netlify/* /index.html 200
Vercelrewrites in vercel.json
API exclusionSeparate location
Asset cacheexpires 1y on the /assets/
HTML cacheno-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.html for the unknown routes. The refresh and the deep link fail without the fallback.
  • The try_files is 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 the index.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. The index.html has the no-cache. The new deploy invalidates the HTML but reuses the old assets.
  • The platform-specific configuration exists. The Vite preview, the Netlify _redirects, the Vercel rewrites. 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.

Was this helpful?


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!