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.
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