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.

A soft 404 compared with a real 404 Two response paths. In the soft case every unmatched path returns index.html with a 200 status and the router renders a not-found view, so a crawler records a successful page. In the corrected case the edge matches the path against the route table and returns the same markup with a 404 status. Soft 404 — status decided before the router runs /made-up-path cold load catch-all rewrite 200 index.html router renders 404 view too late — bytes sent crawler indexes it as a real page Real 404 — the edge consults the route table first /made-up-path cold load edge matches patterns shared with the client same HTML, status 404 router still renders crawler drops it crawl budget preserved The markup is identical in both rows. Only the status line differs — and only the status line matters to a crawler.
The user sees the same not-found screen either way; the crawler sees a successful page in one case and a missing one in the other.

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);
}
One shared route table feeding both the edge and the client router A single routes module is imported by the edge function, which decides the status code, and by the client router, which decides the rendered view. Duplicating the patterns instead would let the status and the view disagree. routes.ts one list of patterns, one predicate Edge function decides the STATUS CODE 200 for known paths, 404 otherwise Client router decides the RENDERED VIEW route view or not-found view Two copies of the patterns is the bug — the status and the screen drift apart at the next route added.
The status code and the rendered view must be derived from the same patterns, or a page will eventually say "not found" with a 200.

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.

Where the status decision can be made Three tiers are shown: the browser cannot set a status because the response has already arrived, the static host does not know the route table, and the edge function has both the route table and control of the response. Only one tier can answer both questions browser knows the route table cannot set the status the response already arrived static host no route table controls the status it only sees files on disk edge function imports the route table controls the status same body, correct status line
The browser knows which paths are real but cannot say so; the host can say so but does not know — the edge is where both facts meet.

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-store is the safe default.
  • Dynamic segments can be known-but-missing. /users/:id matches /users/99999 even 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 /404 page. 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.