Journey
NoteSEOCloudflare404

A real 404, not a soft 200

The site was live, fast, and safe. Then we typed a deliberately wrong URL into the browser — pangaea.id/a-page-that-never-existed — and our site calmly served a tidy 404 page. The problem: it served it with a status of HTTP 200 OK.

To a visitor, the difference is invisible. To Google, it's a lie — and Google punishes that lie.

Why a static site lies "OK"

Cloudflare Pages makes one reasonable assumption: if a project has no top-level 404.html, it figures you're building a single-page app. So for every URL that doesn't match a file, it serves index.html — the app shell — with a 200 status, and lets the client-side JavaScript decide what page to show.

That's perfect for a classic SPA. But our site is prerendered: every page is a file. So when Pages serves the shell at 200 for a URL that doesn't exist, the result is what SEOs call a soft 404 — a page that says "not found" to human eyes, but says "all good" to a crawler.

Why is that bad? Google explicitly treats a soft 404 as a quality defect. The "empty" page can get indexed, dilute the site's authority, waste crawl budget, and show up in search results as a doorway into nothing. There's exactly one correct status for a page that doesn't exist: 404.

The fix: let Cloudflare walk up the tree

One sentence in the Cloudflare Pages docs unlocks the whole thing:

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.

Cloudflare Pages docs

So the fix isn't code — it's putting one file in the right place. With a dist/404.html present, Cloudflare stops guessing SPA: for any unknown URL it serves that file with a real 404 status. It walks up the directories from the requested path until it finds one.

Because our site is bilingual, we need two files, not one:

/id/junk-pathlook for dist/id/404.htmlfound → Indonesian 404
For an Indonesian path, Cloudflare finds dist/id/404.html first — before it ever reaches dist/404.html — so an Indonesian visitor gets a 404 in their language.

Walking up the tree, /id/<junk> meets dist/id/404.html (the Indonesian 404) before it reaches dist/404.html (the English one at the root). That's why we ship both: not redundancy, but the condition for an Indonesian visitor not to land on an English not-found page.

Wiring it without writing a file by hand

We don't like hand-authored assets — both would drift the moment the design changes. So the 404 is a real React page (/404 + /id/404), prerendered like everything else. Then a post-build script, gen-404.mjs, copies both into the spot Cloudflare looks:

What SSG prerenders

  • dist/404/index.html
  • dist/id/404/index.html
  • Great for in-app navigation, but not what Cloudflare checks as it walks the tree

What gen-404.mjs copies

  • dist/404.html
  • dist/id/404.html
  • These are the files Cloudflare serves with a 404 status for an unknown URL

dirStyle: 'nested' renders the route as 404/index.html; gen-404 lifts it up one level to 404.html. The script runs before csp-hash.mjs, so the copied pages' inline bootstrap scripts get hashed into the CSP too.

Two traps that bit us

A 404 page must be noindex — it's not a real destination. But our index.html used to carry one robots index, follow tag, and the per-route page added its own. For the 404, that meant two contradictory directives: index and noindex on the same page. We moved the robots tag off index.html into PageHead, so each page emits exactly oneindex for real pages, noindex, follow for the 404 — with no contradiction. We also cut the 404 from canonical, hreflang, and sitemap.xml: a not-found page mustn't claim a canonical URL or locale alternates.

Prove it locally

vite preview doesn't know Cloudflare's nearest-404.html trick, so we taught our own preview server (serve-dist.mjs) to mirror that behavior: for an unknown URL it walks up the tree to the nearest 404.html and serves it with a 404 status. Now after a single npm run build, the preview returns 404 for a bad English path, 404 for a bad Indonesian path (in Indonesian), and 200 for real pages — exactly like the edge.

Do

  • Ship a top-level 404.html (per locale) so a static host returns the right status
  • Mark the 404 noindex and pull it from the sitemap, canonical, and hreflang
  • Make sure there's exactly one robots directive per page
  • Prove the status code locally — don't trust that the page merely looks right

Don't

  • Rely on a pretty 404 page served at 200 — that's a soft 404, and Google penalizes it
  • Wrap head tags in a fragment with react-helmet — they'll silently disappear
  • Index a not-found page or list it in the sitemap

The point of it

A status code is how your site tells machines the truth. A beautiful 404 page means nothing if it lies "200 OK" in the header. The full technical write-up — the soft-404 mechanism, the nearest-404.html tree walk, and the SSG wiring — is in our new article: Why a static site returns 200 for missing pages →.

This closes the go-live arc: the domain pointed, the site shipped, one canonical address, fast and safe — and now it tells the truth even when you ask for a page that isn't there.

Sources

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