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.
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:
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.htmldist/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.htmldist/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 one — index 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
noindexand pull it from the sitemap, canonical, and hreflang - Make sure there's exactly one
robotsdirective 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