Notes from production
CloudflareSEO404

Soft 404: make Cloudflare Pages return a real 404

Type a URL that doesn't exist on a static site and you often get something strange: a page that looks like "not found", but that the server reports as 200 OK — success. To your eyes there's no difference. To Google, it's a defect — it's called a soft 404, and there's a penalty for it.

This article explains why it happens, then shows how to make Cloudflare Pages return a real 404 for a missing page — including the trick for a bilingual site.

What a status code is, in one minute

Every time a browser asks for a page, the server replies with a status code — a number that sums up the outcome, before any content. Three matter here:

  • 200 OK — "Here's the page you asked for."
  • 301 Moved Permanently — "That page lives at a different address now."
  • 404 Not Found — "That page doesn't exist."

Visitors rarely see this number, but crawlers — Googlebot, Bingbot, ClaudeBot — act on it. A status code is how your site tells machines the truth.

Why a static site returns 200 for a missing page

Here's the trap. A static host like Cloudflare Pages matches each URL to a file. Ask for /pricing, it serves pricing/index.html. But ask for /some-junk-path, no file matches — and then what?

Cloudflare Pages makes one assumption: if your project has no top-level 404.html, it figures you're building a single-page app (SPA). In a classic SPA, the server must serve one index.html for every path and let JavaScript do the routing — so Pages dutifully serves index.html at 200 for any unknown path.

For a real SPA that's correct. For a prerendered site like this one — where every page is a real file — it's wrong. The visitor sees a 404 page (the client-side router renders it after loading), but the header already said 200 OK. That's a soft 404: one signal to humans, a different one to machines.

Why does Google care? Its docs name the soft 404 as a quality problem: that "empty" page can get indexed, waste crawl budget, dilute the site's authority, and surface in search results as a doorway into nothing. The correct status for a page that doesn't exist is 404 — always.

The fix: nearest-404.html and the tree walk

One sentence in the Cloudflare Pages docs is the entire fix:

If a file is not found, it will continue to look up the directory tree for a matching 404.html file, ending in /404.html.

Translated: drop in a 404.html, and Cloudflare stops guessing SPA. For any unknown URL, it walks up the directories from the requested path until it finds a 404.html — then serves it with a real 404 status. No more soft 200.

GET /id/junk-pathmatches no filelook for dist/id/404.htmlpresent → serve · Indonesian 404walk up: dist/404.htmlfallback (English 404 at root)An unknown English path hits dist/404.html directly →HTTP 404a real status — not a soft 200
For an Indonesian path, Cloudflare finds dist/id/404.html first — so an Indonesian visitor gets a 404 in their language — then walks up to dist/404.html as the fallback.

Why two files, not one

This site is bilingual: English at /, Indonesian at /id/*. The tree walk finds the deepest matching 404.html first, so we ship two:

  • dist/404.html — catches /some-junk-path (the English 404).
  • dist/id/404.html — catches /id/some-junk-path (the Indonesian 404).

Without the Indonesian file, /id/<junk> would walk up past /id/ and hit dist/404.html — an Indonesian visitor stranded on an English not-found page. The Indonesian file catches it first. The status stays 404; only the language changes.

Wiring it in the SSG build

We don't hand-author those two HTML files — both would drift the moment the design changes. The flow:

1. The 404 is a real page, prerendered. There's a route for /404 and /id/404, same as any other page, so both prerender to full static HTML.

2. dirStyle: 'nested' renders them nested. vite-react-ssg writes the route as 404/index.html, not 404.html:

dist/404/index.html        ← what SSG prerenders
dist/id/404/index.html

Great for in-app navigation — but not where Cloudflare looks as it walks the tree.

3. A post-build script lifts them up one level. gen-404.mjs copies each nested page to the top-level spot Cloudflare reads:

dist/404/index.html     →  dist/404.html      (serves /<junk>)
dist/id/404/index.html  →  dist/id/404.html   (serves /id/<junk>)

It runs before csp-hash.mjs, so the copied pages' inline bootstrap scripts get hashed into the Content-Security-Policy too — no gaping CSP hole on the 404 page.

Clean up the SEO signals

A 404 page isn't a real destination, so its signals should say so:

  • noindex. The 404 emits <meta name="robots" content="noindex, follow"> — don't index it, but do follow the links back out.
  • No canonical, no hreflang. A not-found page mustn't claim a canonical URL or locale alternates. Both are skipped on the 404.
  • Excluded from sitemap.xml. The sitemap lists pages you want indexed; the 404 isn't one.

One last trap, for engineers: when making the robots tag conditional, don't wrap the head tags in a JSX fragment. react-helmet (which vite-react-ssg uses) drops head tags nested inside a fragment — they vanish from the HTML silently. Flatten to conditional children — each tag a direct child of <Head>.

Prove it locally

vite preview doesn't mirror Cloudflare's nearest-404.html walk, so our own preview server does. After a build, it walks up the tree for an unknown URL and serves the nearest 404.html with a 404 status:

npm run build
npm run preview:headers -w @pangaea/labs   # serves dist/ like the edge

curl -I http://localhost:8788/some-junk-path      # → HTTP 404 (English)
curl -I http://localhost:8788/id/some-junk-path   # → HTTP 404 (Indonesian)
curl -I http://localhost:8788/pricing             # → HTTP 200

If all three are right locally, the edge will agree. Don't trust that the page merely looks right — check the status code.

The bottom line

A static site serves files, and one missing file easily ends up at the app shell on 200 — a soft 404 that Google penalizes. The fix isn't code, it's a file in the right place: ship a top-level 404.html (one per locale), let Cloudflare walk the tree, and mark the page noindex and keep it out of the sitemap. Now the site tells the truth — even when you ask for a page that isn't there.

The story behind this fix is in the diary entry A real 404, not a soft 200 →. It's part of the same go-live arc as Fast, safe SSL & security on the edge, CI/CD: GitHub Actions → Cloudflare Pages, and Root → www: the 301 redirect.

Sources

  1. Cloudflare Pages — Not Found behavior (nearest 404.html)
  2. Google Search — soft 404 errors
  3. MDN — 404 Not Found