Returning Real 404 Status Codes from a SPA
After reading this you will be able to make an unmatched route in a client-rendered application answer with a genuine HTTP 404, so search engines drop the URL from their index instead of treating your not-found screen as a real page.
← Back to Fallback Routing Strategies
Prerequisites
Core Concept
A soft 404 is a page that looks like an error to a human and looks like a success to a machine. The catch-all rewrite that makes deep links work is precisely what creates them: every unmatched path receives index.html with a 200, so /reports/2026, /reports/nonsense and /completely-made-up all return identical successful responses. The router renders a not-found view for the last two, but by then the status code has already been sent.
The consequences are all crawler-side and all slow to appear. Search engines index the not-found page under thousands of URLs, then flag them as soft 404s and stop trusting the site’s status codes generally. Crawl budget is spent re-fetching URLs that will never be real. Link checkers report a healthy site while every one of those links is broken. And any monitoring built on status codes is blind, because nothing ever fails.
The fix cannot live in the browser: by the time JavaScript runs, the response has been fully received. It must happen before the response is sent, which means the decision has to move to whatever tier answers the request — an edge function, a worker, or the origin. That tier needs a copy of the route patterns so it can distinguish a path the application serves from a path it does not.
Implementation
Export the route patterns from a single module so the client router and the edge function cannot disagree. Matching at the edge only needs to answer “does any route claim this path?” — it does not need to render anything.
// TypeScript 5.x — routes.ts, imported by BOTH the app and the edge function
export const routePatterns = [
"/",
"/reports",
"/reports/:year",
"/users/:id",
"/users/:id/activity",
"/account",
] as const;
// Compiled once at module scope, not per request.
const compiled = routePatterns.map(
(p) => new RegExp("^" + p.replace(/:[^/]+/g, "[^/]+").replace(/\/$/, "") + "/?$"),
);
export function isKnownRoute(pathname: string): boolean {
return compiled.some((re) => re.test(pathname));
}
// TypeScript 5.x — Cloudflare Workers / edge function entry point
import { isKnownRoute } from "./routes";
export default {
async fetch(request: Request, env: { ASSETS: Fetcher }): Promise<Response> {
const url = new URL(request.url);
// Real files and API paths are never our concern.
if (url.pathname.startsWith("/assets/") || url.pathname.startsWith("/api/")) {
return env.ASSETS.fetch(request);
}
// The shell is the same document either way — only the status differs.
const shell = await env.ASSETS.fetch(new Request(new URL("/index.html", url), request));
if (isKnownRoute(url.pathname)) {
return shell; // 200, as delivered by the static host
}
return new Response(shell.body, {
status: 404,
headers: {
// Preserve content type and encoding from the original shell response.
...Object.fromEntries(shell.headers),
// Never let an error response be cached by a shared cache.
"Cache-Control": "no-store",
"X-Robots-Tag": "noindex",
},
});
},
};
Two details carry most of the value. The body is the unchanged shell, so the application still boots and renders its own styled not-found view — users get the designed experience, not a bare server error page. And X-Robots-Tag: noindex belts-and-braces the status code for crawlers that have already queued the URL.
The client side needs one small change so the two agree about what “not found” means:
// TypeScript 5.x — the client's not-found view, aligned with the edge
import { isKnownRoute } from "./routes";
export function resolveView(pathname: string) {
if (!isKnownRoute(pathname)) {
// Same predicate the edge used, so the rendered view always matches the status.
return { view: "not-found", title: "Page not found" };
}
return matchRoute(pathname);
}
Verification
# A known route returns 200; an unknown one returns 404 with the same markup.
curl -sI https://your-site/reports/2026 | head -n 1 # HTTP/2 200
curl -sI https://your-site/reports/2026/x | head -n 1 # HTTP/2 404
# The 404 body is still the app shell, so users get the styled page.
curl -s https://your-site/nope | grep -c 'id="app"' # 1
In Search Console, the “Soft 404” bucket under Page Indexing should drain over the following crawl cycles; a sudden increase in “Not found (404)” during the same period is the expected, healthy counterpart. Locally, Lighthouse’s SEO audit does not check status codes for you, so this is one of the cases where the command line is the tool.
Gotchas
- Do not cache 404 responses at a shared cache. A path that becomes real later — a new report year, a newly created user — would keep returning 404 until the cache expired.
Cache-Control: no-storeis the safe default. - Dynamic segments can be known-but-missing.
/users/:idmatches/users/99999even when user 99999 does not exist. Pattern matching gets you a real 404 for unknown shapes; unknown entities need a data lookup at the edge or a server-rendered check. - Do not redirect unmatched paths to a
/404page. That produces a 302 followed by a 200, which is a different flavour of the same problem and also loses the URL the user asked for. - Keep the route list in sync automatically. If the edge imports the same module the router uses, adding a route cannot desync them; if it is hand-maintained, it will drift within a month.
FAQ
Does a client-rendered 404 view hurt SEO on its own? The view is fine — the status code is the problem. Crawlers classify a 200 response whose content says “not found” as a soft 404, which wastes crawl budget and reduces trust in the site’s other status codes.
Can I set the status code from JavaScript? No. The status line is sent before the body, and the body is what carries your JavaScript. Anything that decides the status must run before the response leaves the server.
What if I have no edge function available? Server-render the not-found case only, or pre-render the known route list into the host’s redirect configuration so unmatched paths fall through to a real 404 file. On some static hosts a final /* /404.html 404 line achieves this directly.
Should the 404 page be noindex as well as 404? Belt and braces: the status code is the authoritative signal, but X-Robots-Tag: noindex costs nothing and covers crawlers that have already queued the URL from an old sitemap.
How do I handle a route that exists but whose record was deleted? Return 410 Gone if the deletion is permanent and 404 if it might come back. Both remove the URL from the index; 410 does it faster.
Related
- Fallback Routing Strategies — the full picture for paths that do not resolve, including offline and error cases.
- Configuring SPA Fallback Rewrites on Static Hosts — the rewrite that makes deep links work and creates soft 404s as a side effect.
- When to Choose SPA over MPA for SEO — the wider crawlability tradeoffs behind client-side rendering.