We moved our API docs to a clean, SEO-friendly URL: docs.jo4.io/docs/api-reference/. The docs are a single-page app hosted on GitHub Pages, proxied through a Cloudflare Worker. It worked great — until someone shared a deep link. Load /docs/api-reference/ directly and GitHub Pages returns a 404, because there's no file at that path; the route only exists inside the SPA router that hasn't loaded yet.
TL;DR: A SPA deep link is a router path, not a file. Static hosts 404 on it before the JS boots. That's bad for SEO (crawlers see a 404), bad for sharing, and ugly (the 404.html redirect flashes before the route renders). Fix it at the edge: in the Worker, when the origin 404s on an extensionless path, serve /docs/index.html with a 200 so the SPA can render the route.
The Problem
Client-side routing means /docs/api-reference/ is a virtual path. The SPA's JavaScript reads window.location, matches a route, and renders — all in the browser. But when you load that URL cold, the server is asked for a file at /docs/api-reference/, and there isn't one. GitHub Pages returns 404.
Three consequences, all bad:
- SEO. Crawlers get an HTTP 404 for a real page. It won't index.
- Sharing. Paste the link anywhere and the recipient's first load 404s.
- UX. The common GitHub Pages
404.html→ JS redirect hack works, but the 404 page flashes visibly before the SPA route loads.
The Fix: An Edge SPA Fallback
The Worker already proxies the docs origin. So intercept the 404 there. When the origin returns 404 for a path that looks like a route — no file extension — fetch index.html and return it with a 200:
async function proxyDocs(request, targetUrl) {
const response = await fetch(targetUrl, {
method: request.method,
headers: request.headers,
});
const pathname = new URL(request.url).pathname;
// SPA fallback for deep links: when the origin 404s on a React Router-style path
// (no file extension, e.g. /docs/api-reference/), serve /docs/index.html with 200
// so the SPA can render it. Without this, deep links return 404 — bad for SEO,
// broken for crawlers, ugly UX.
//
// Skip paths WITH a file extension (.js, .css, .png, .json) — those are real
// assets that should stay 404 when missing.
if (response.status === 404 && !/\.[a-z0-9]{1,8}$/i.test(pathname)) {
const fallback = await fetch(GITHUB_PAGES_ORIGIN + '/docs/index.html', {
method: request.method,
headers: request.headers,
});
return withSpaCacheHeaders(pathname, fallback);
}
return withSpaCacheHeaders(pathname, response);
}
Now a cold load of /docs/api-reference/ gets a real 200 with the SPA shell, the router boots, and the page renders — no flash, crawler-friendly.
The Extension Check Is the Whole Trick
The one rule that keeps this from breaking everything: only fall back for extensionless paths. A missing main.abc123.js or logo.png is a genuine 404 you want to preserve — turning those into a 200 HTML page would mask real asset failures and confuse the browser (a <script> that receives HTML errors loudly). The regex \.[a-z0-9]{1,8}$ says "has a file extension → leave the 404 alone." Routes (which end in / or a bare segment) get the fallback; assets don't.
Cache Headers Matter Too
The fallback runs through the same withSpaCacheHeaders helper as normal responses — the one that prevents the classic "stale index.html points at a deleted asset" trap. The SPA shell must not be cached as aggressively as a hashed asset, or you'll serve an old shell that references chunks that no longer exist. Same discipline as any SPA deploy, applied at the edge.
Don't Turn Real 404s Into 200s
The flip side of the fallback is a failure mode worth naming: if you're too aggressive, you'll mask genuine problems. A mistyped route, a deleted page, a broken internal link — all of those should surface as something a human or a monitor notices. If every unknown path silently returns the SPA shell with a 200, a typo in your own navigation looks "fine" (it renders the app) while quietly sending users to a blank route. The extension check is your first guard (assets stay 404), but consider a second: let the SPA itself render a real in-app 404 view for routes it doesn't recognize, so the edge says 200 (good for crawlers and sharing) while the app still tells the user "this page doesn't exist." Edge fallback and in-app not-found are complementary, not redundant.
A Note on Crawlers and Caching
Serving a 200 for deep links is what makes them indexable, but crawlers also care that the content is stable and correct at that URL. Two things follow. First, don't let the fallback serve a stale shell that references deleted assets — run it through the same cache-header discipline as every other response so the shell isn't over-cached. Second, if your docs are content that matters for SEO, make sure the SPA renders meaningful <title> and meta tags per route once it boots; the edge fallback gets the crawler in the door with a 200, but what it finds inside still has to be worth indexing. The Worker fix solves the status code; the app has to solve the content.
Lessons Learned
- Deep links are routes, not files. Static hosts 404 on them cold. If you care about sharing or SEO, you need a fallback somewhere in the request path.
- Do it at the edge. A Worker already in front of the origin is the cleanest place — no origin config, no
404.htmlredirect flash. - Gate the fallback on "no extension." Falling back on missing assets masks real failures. Only virtual routes should become
index.html.
Where do you put your SPA fallback — host config, edge, or a 404 hack? Let me know.
Building jo4.io - a URL shortener with developer docs served from Cloudflare's edge.