Configuring SPA Fallback Rewrites on Static Hosts

After reading this you will be able to write the catch-all rewrite rule for any mainstream static host so that a cold load of a nested route serves your app shell with a 200 — while genuine assets, API paths and missing files still behave correctly.

← Back to Fallback Routing Strategies

Prerequisites

Core Concept

A History-mode router resolves /reports/2026 in JavaScript, but that only happens once JavaScript is running — and JavaScript only runs once the browser has received an HTML document. On a cold load, the browser asks the host for the literal path /reports/2026. A static host looks for a file at that path, does not find one, and returns 404 before your application has had any opportunity to exist.

The fix is a rewrite, not a redirect. A redirect (301/302) changes the URL in the address bar, which destroys the very information the router needs. A rewrite serves the contents of index.html while leaving the requested URL intact, so the document loads, the router boots, reads location.pathname, and renders the right view. The status code stays 200.

The rule must be a fallback of last resort. If it matches too eagerly it will serve HTML for a missing JavaScript chunk, and the browser will try to parse <!doctype html> as a module — producing the familiar and thoroughly misleading Unexpected token '<' error. Every host expresses “only if nothing else matched” differently, and getting that ordering right is most of the work.

Request resolution order on a static host with a SPA fallback A request is checked against real files first, then against explicit rules such as API proxies, and only then falls through to the catch-all rewrite that serves index.html with a 200 status. GET /reports/2026 1 · Real file? /assets/app.a1b2.js served as-is 2 · Explicit rule? /api/* → upstream never rewritten 3 · Catch-all rewrite serve /index.html status 200, URL unchanged Order is the entire rule A catch-all placed before the file check serves HTML for missing chunks. Rewrite, not redirect — a 301 would rewrite the address bar and destroy the path the router needs.
The fallback must be the last rule evaluated: real files first, explicit rules second, catch-all only if nothing matched.

Implementation

Each host below expresses the same three-step order. The comments mark the line that makes it a fallback rather than a blanket rewrite.

# nginx 1.24 — the canonical formulation
server {
  root /var/www/app;

  # API paths are proxied BEFORE the fallback can see them.
  location /api/ {
    proxy_pass http://backend;
  }

  # Hashed assets: if the file is genuinely missing, return a real 404 so a
  # stale chunk request fails loudly instead of parsing HTML as JavaScript.
  location /assets/ {
    try_files $uri =404;
  }

  location / {
    # $uri first (real file), then $uri/ (directory index), then the shell.
    # /index.html carries no "=404", so it is the terminal fallback.
    try_files $uri $uri/ /index.html;
  }
}
# Apache 2.4 — .htaccess at the document root
<IfModule mod_rewrite.c>
  RewriteEngine On

  # Never rewrite something that exists on disk.
  RewriteCond %{REQUEST_FILENAME} -f [OR]
  RewriteCond %{REQUEST_FILENAME} -d
  RewriteRule ^ - [L]

  # Never rewrite API paths or hashed assets.
  RewriteRule ^api/ - [L]
  RewriteRule ^assets/ - [L,R=404]

  # Everything else renders the shell, with the URL left intact.
  RewriteRule ^ index.html [L]
</IfModule>
// vercel.json — rewrites run after the filesystem check by default
{
  "rewrites": [
    { "source": "/api/:path*", "destination": "/api/:path*" },
    { "source": "/((?!assets/).*)", "destination": "/index.html" }
  ]
}
# Netlify and Cloudflare Pages — _redirects, first match wins, so order matters.
# The 200 status is what makes this a rewrite rather than a redirect.
/api/*        https://backend.example.com/api/:splat   200
/assets/*     /assets/:splat                           404
/*            /index.html                              200
// Cloudflare Pages alternative — static.json style config is not used;
// _redirects above is the supported mechanism. For S3 + CloudFront, map the
// distribution's custom error responses instead:
{
  "CustomErrorResponses": [
    {
      "ErrorCode": 403,
      "ResponsePagePath": "/index.html",
      "ResponseCode": 200,
      "ErrorCachingMinTTL": 0
    },
    {
      "ErrorCode": 404,
      "ResponsePagePath": "/index.html",
      "ResponseCode": 200,
      "ErrorCachingMinTTL": 0
    }
  ]
}

S3 behind CloudFront is the awkward case: a private bucket returns 403 rather than 404 for a missing key, so both codes must be mapped. ErrorCachingMinTTL: 0 matters too — without it CloudFront caches the error-to-shell mapping and keeps serving the shell after you deploy a real file at that path.

What happens when the catch-all is too greedy A missing JavaScript chunk request matches the catch-all rule and receives index.html with a 200 status and an HTML content type, which the module parser rejects with an unexpected token error. The failure this rule ordering prevents stale chunk request /assets/route.9f2c.js catch-all matches serves index.html, 200 module parser rejects HTML Uncaught SyntaxError: Unexpected token '<' Why it is so hard to diagnose The status is 200, so nothing looks failed in the Network panel — only the response type is wrong. Excluding the asset prefix from the fallback turns a confusing syntax error into an honest 404.
Excluding the hashed-asset prefix converts a baffling Unexpected token '<' into a plain 404 that points straight at the real problem.

Verification

Test the cold load, not the in-app click — clicking into a route from the index never touches the host and will pass even with no rule at all.

# 1. A deep route must return 200 and HTML.
curl -sI https://your-site/reports/2026 | head -n 2
# HTTP/2 200
# content-type: text/html; charset=utf-8

# 2. A missing asset must return 404, NOT 200 with HTML.
curl -sI https://your-site/assets/does-not-exist.js | head -n 2
# HTTP/2 404

# 3. An API path must reach the API, not the shell.
curl -s https://your-site/api/health | head -c 40
# {"status":"ok"}
// @playwright/test v1.44 — the same three checks as a deployable test
import { test, expect } from "@playwright/test";

test("deep links survive a cold load", async ({ page, request }) => {
  const doc = await page.goto("/reports/2026");
  expect(doc?.status()).toBe(200);
  await expect(page.locator("[data-route='reports']")).toBeVisible();
  // The address bar must be untouched — a redirect would have rewritten it.
  expect(page.url()).toContain("/reports/2026");

  // A missing chunk must fail honestly rather than returning the shell.
  const missing = await request.get("/assets/nope.js");
  expect(missing.status()).toBe(404);
});
Host support for the three rules a SPA fallback needs A matrix of six hosts against three capabilities: excluding an asset prefix, proxying an API path, and expressing the catch-all rewrite. Static object storage lacks rule ordering and must use error-document mapping instead. host exclude assets proxy /api catch-all nginxtry_files =404locationtry_files ApacheRewriteRuleRewriteRuleRewriteRule Netlify / CF Pages_redirects 404_redirects 200/* last Vercelnegative regexrewritesrewrites S3 + CloudFrontno rule orderingseparate origin403/404 → shell
Only the object-storage row cannot express ordered rules — which is why it maps error responses to the shell instead.

Gotchas

  • A trailing-slash mismatch counts as a different path on some hosts. If /reports/ serves the shell but /reports returns 404, add an explicit normalisation rule rather than relying on the router to cope.
  • Cloudflare Pages and Netlify apply _redirects top to bottom and stop at the first match, so a /* line placed above your API proxy captures everything below it. Put the catch-all last, always.
  • CloudFront caches the error-to-shell mapping. Deploying a real file at a previously missing path will keep serving index.html until the cached error response expires, which is why ErrorCachingMinTTL should be zero.
  • Serving the shell with a 200 for genuinely missing pages creates soft 404s. The rewrite is correct for routes your app knows about; for paths it does not recognise, follow up with returning real 404 status codes from a SPA.

FAQ

Why a rewrite and not a redirect? A redirect changes the URL in the address bar before your JavaScript loads, so the router never sees the path the user actually requested. The rewrite serves different content for the same URL, which is exactly what a client-side route needs.

Why does this work in development but 404 in production? Every popular dev server ships with the catch-all rewrite enabled by default, so the missing production rule is invisible until deployment. Always test a cold deep-link against the real host.

Should the shell be cached? Cache the hashed assets aggressively and the shell barely at all — Cache-Control: no-cache on index.html means the browser revalidates it on every navigation, so a deploy takes effect immediately while the assets stay in cache.

What about paths that should genuinely 404, like /robots.txt typos? Leave them to the catch-all and let the router render its not-found view. The only paths worth excluding from the fallback are those where an HTML response would be actively harmful: script and style assets, API endpoints, and generated files such as sitemaps.

Does this break server-side rendering? Yes, and deliberately: if you server-render routes, the origin already answers every path and no fallback is needed. The rewrite is for hosts that only serve static files, the tradeoff examined in SPA shell caching vs full SSR.