TezBase

Guides · updated 2026-09-08

Cloudflare Pages answers 200 for every missing URL. Fix the soft 404 before Google indexes your homepage twenty times

Symptom: the site is live, the pages you wrote are correct, and then you mistype a URL and the homepage appears. Not a 404 page. The homepage, with HTTP/1.1 200 OK.

curl -sI https://yourdomain.com/no-such-page-xyz | head -1
HTTP/1.1 200 OK

We hit this an hour after putting tezbase.com on Pages, on the same day we submitted a sitemap to Search Console and pushed ten URLs through IndexNow. Bad order: we were inviting crawlers to a site where every wrong guess returns a valid page.

Why it happens

Cloudflare Pages serves static assets by path. When the path matches no asset, it looks for a 404.html near the requested path in the build output. If it does not find one anywhere, it falls back to the root index.html and returns it with status 200. That fallback is deliberate: it is what a single-page app needs, so the client-side router can handle the address. On a plain static site it is wrong, and nothing in the dashboard warns you.

So the trigger is not a setting. It is the absence of a file.

The fix

Put a 404.html in the directory you deploy, not in your sources. Whatever you run wrangler pages deploy <dir> against is the whole truth; a file in src/ that your generator does not copy will not be there.

Ours is a plain page with the site header, a sentence, and links to /log/ and /guides/. It is copied into the output by the generator along with the IndexNow key and the favicon:

// build: everything in site-static/ goes to the output directory as is
if (existsSync(STATIC))
  for (const f of readdirSync(STATIC))
    writeFileSync(join(OUT, f), readFileSync(join(STATIC, f)));

Deploy, then check both halves. One command is not enough: you need to see that the missing page changed status and that the real pages did not:

curl -sI https://yourdomain.com/no-such-page-xyz | head -1   # want 404
curl -sI https://yourdomain.com/ | head -1                    # want 200
curl -s  https://yourdomain.com/no-such-page-xyz | grep -i "<title"

The third line matters. A 404.html that Pages serves with the right status but that looks like your homepage is still a bad experience, and if it accidentally carries the homepage's <link rel="canonical"> you have handed Google a canonical tag pointing home from every junk URL.

What Google does with a soft 404

Search Console reports it under Pages → Not indexed → Soft 404, and its close relative, Duplicate without user-selected canonical, when the same homepage body shows up under many addresses. Neither is fatal. Both waste crawl budget on a new site that has almost none, and both take days to clear after you fix them, because the fix is only seen on recrawl.

If you have already submitted a sitemap, do not resubmit it in a panic. The 404 status is the signal; the crawler will drop those URLs on its own pass.

Two neighbours of the same bug

Trailing slashes. Directory-style output (/guides/name/index.html) answers on both /guides/name/ and /guides/name, and Pages redirects the second to the first with a 308. That is fine, but pick one form and use it consistently in your sitemap and internal links, or you will pay for a redirect hop on every crawl.

Automatic analytics injection. Cloudflare Web Analytics offers to insert its beacon for you. On our Pages project it did not appear in the served HTML at all. We stopped guessing and put the snippet in the page template ourselves, with the token from the environment:

<script defer src="https://static.cloudflareinsights.com/beacon.min.js"
        data-cf-beacon='{"token": "YOUR_TOKEN"}'></script>

Then verified against production rather than the dashboard, which reports what it intends to do:

curl -s https://yourdomain.com/ | grep -c cloudflareinsights

Reading your own numbers without opening the dashboard

Once the beacon works you can query Web Analytics over the GraphQL API and skip the panel entirely, which matters if a scheduled job is supposed to write the numbers into a report. The dataset is rumPageloadEventsAdaptiveGroups, grouped by whichever dimensions you select:

query ($account: String!, $since: Time!, $site: String!) {
  viewer { accounts(filter: { accountTag: $account }) {
    rumPageloadEventsAdaptiveGroups(
      filter: { datetime_geq: $since, siteTag: $site }
      limit: 25, orderBy: [count_DESC]
    ) { count sum { visits } dimensions { requestHost requestPath } }
  } }
}

Two traps, both of which cost us time today.

dimensions is a selection, not an argument. Writing dimensions: [requestPath] in the argument list returns unknown arg dimensions. Grouping is implied by the fields you select inside dimensions { }.

The snippet token and the site tag are different strings. The token in your data-cf-beacon attribute is not the siteTag the API filters on. Query with the wrong one and you get visits: 0 and count: 0, a clean success response with no error anywhere. We believed a zero for several minutes before checking. To find the real tag, drop the siteTag filter, group by siteTag and requestHost, and read off which tag serves your domain.

That is the same failure mode as the soft 404, one level up: a broken measurement answers plausibly instead of failing. On a static host the only proof of what is served is a request against production, and the only proof that a number is your number is a dimension with your hostname in it. The dashboard describes configuration. curl and a grouped query describe reality.