Web / SEO / Cloudflare

When HTTP 200 lies: fixing a Cloudflare Workers React SPA for search

This site once returned HTTP 200 for its homepage, robots.txt, sitemap.xml, and editorial hub. A status-only monitor would have marked all four healthy. In reality, two URLs were serving the React homepage through an SPA fallback, and the hub did not exist as a distinct document.

· About 9 minutes · Verified in production

The useful rule: a successful status code proves that a server responded. It does not prove the response has the correct media type, body, canonical URL, language, or purpose. Verify the response contract, not just the status.

The production failure

DrNoGreasy Studio runs a React single-page application on Cloudflare Workers Static Assets. Before the repair, public requests produced this result:

URLObserved responseWhy it was wrong
/200 HTMLThe React shell loaded, but the original #root contained no meaningful content.
/robots.txt200 text/plainCloudflare-managed directives were followed by the SPA homepage HTML.
/sitemap.xml200 text/htmlThe body was the homepage, not XML.
/stories/200 HTMLThe route returned the same app shell instead of a distinct editorial hub.

The failure was not hypothetical and it was not discovered from a generic SEO score. It came from reading the public Content-Type headers and bodies, parsing the sitemap, comparing canonical URLs, and checking whether supposedly different pages actually had different content.

Root cause: the SPA fallback turned absence into success

Cloudflare supports assets.not_found_handling = "single-page-application". Its current React and Vite guide explains that static asset routing first looks for a matching file and then falls back according to the configured not-found behavior. The dedicated SPA routing documentation describes the platform behavior in detail.

That fallback is useful for client-side routes. It is not a content generator. If sitemap.xml, robots.txt, or an editorial route has no physical asset or explicit Worker response, the same fallback can make a missing resource look successful.

The smallest reliable fix was not a framework migration. It was to make the resources that must be distinct exist as real deployed assets, then verify their public response contracts.

Fix 1: ship real robots and sitemap assets

The deployment root now contains actual robots.txt and sitemap.xml files. The minimum useful robots body is intentionally boring:

User-agent: *
Allow: /

Sitemap: https://drnogreasy.life/sitemap.xml

The sitemap lists only public URLs that return a distinct document with a matching canonical. Google describes a sitemap as a discovery signal, not an indexing guarantee, in its sitemap documentation. That boundary matters: a valid sitemap can prove technical eligibility, but it cannot prove that a search engine has indexed the URLs.

Fix 2: give the React root crawlable fallback content

Google can render JavaScript, but its JavaScript SEO guidance still treats crawling, rendering, and indexing as separate stages. For a small studio homepage, replacing the entire stack with server-side rendering would have been disproportionate.

Instead, the original HTML now contains a compact, crawlable fallback inside #root: one primary heading, a concise description, and ordinary anchor links to the editorial hub and deep articles. React replaces that fallback when the bundle loads. The static and rendered versions describe the same studio and resources, so the approach does not show search engines a different offer.

<div id="root">
  <main id="main" class="seo-fallback">
    <h1>A one-person studio building auditable software</h1>
    <a href="/en/stories/">English developer resources</a>
    <a href="/en/tools/production-readiness/">Free readiness tool</a>
  </main>
</div>

Fix 3: make editorial routes physical HTML documents

The hub and case studies now have their own index.html files. Each document has a unique title, description, canonical, visible H1, body, and matching structured data. Internal navigation uses real <a href> links rather than click handlers; Google documents this pattern in its crawlable link guidance.

The English and Traditional Chinese versions also declare reciprocal hreflang links and expose a visible language switch. Google's localized-page guidance recommends that each version list itself and the other language versions. Canonical URLs stay language-specific instead of collapsing the translations into one URL.

How the production verification works

A deployment is not accepted because CI is green or a push succeeded. The public site is read again after the Cloudflare build finishes. The verification contract checks:

  1. The local commit, remote main, and intended deployment revision agree.
  2. The homepage returns HTML whose original body contains a heading and crawlable deep links.
  3. robots.txt returns plain text, contains no HTML shell, permits search, and declares the sitemap.
  4. sitemap.xml returns XML that a parser can read and contains every expected canonical URL exactly once.
  5. The English and Traditional Chinese pages return 200, use distinct canonical URLs, and declare reciprocal alternates.
  6. The hub, article, and homepage bodies are actually different; none is an SPA shell masquerading as the requested document.
  7. A real mobile browser renders one main region and one H1 without horizontal overflow or console errors.

The repository runs these invariants through npm run verify:seo. This turns the original incident into a regression gate: adding a page without updating the sitemap, breaking a language cluster, or pointing the English hub back to the wrong language fails the build-time check.

Cloudflare Managed Content Signals changed the robots body

After deployment, Cloudflare Managed Content Signals prepended managed directives to the custom robots file. The public response became much larger than the source file while remaining valid plain text and explicitly allowing search.

That means file size is a poor assertion. The useful checks are semantic: no HTML app shell, no unintended crawler block, the expected search directive, and the sitemap declaration. Platform-managed output should be validated by what it means, not by assuming the production bytes must equal the repository bytes.

After crawlability: a privacy-minimal D1 page-view baseline

Fixing discovery created a second evidence problem: deployment and indexability still could not answer whether anyone loaded a page. On August 13, 2026, this site deployed a first-party Worker endpoint at /api/analytics/view and an aggregate query at /api/analytics/summary. The browser sends only the normalized pathname and document language. A Cloudflare D1 row stores only the Taipei calendar day, one of the 15 allowed public paths, the language, and an incrementing count.

The event route accepts only https://drnogreasy.life, rejects unknown paths, stops request bodies after 512 bytes, and applies a Cloudflare Rate Limiting binding before D1 writes. The edge-provided source IP is combined with the Taipei day and SHA-256 hashed only for a 10-events-per-minute abuse limit. Neither the raw IP nor that daily hash is written to D1 or returned by the summary. Cloudflare documents D1 as its serverless SQL database and warns that its Rate Limiting API is local, permissive, and not an exact accounting system.

The public 7-day summary and 30-day summary return aggregate JSON without a token and carry noindex. This is deliberately a page-load counter, not a unique-person claim. It can show which public pages were loaded and whether the weekly and monthly gates moved, while Cloudflare traffic and Search Console remain necessary cross-checks for bot filtering, discovery, impressions, clicks, and indexed state.

What this repair does—and does not—prove

The repair proves that the public resources are fetchable, parseable, internally discoverable, and consistent with their canonical and language annotations. It also reduces the number of JavaScript-only discovery paths.

It does not prove indexation, rankings, clicks, unique visitors, or traffic growth. A public site: search is an incomplete observation, not property-level evidence. The D1 baseline now measures anonymous page loads, but Search Console performance and indexed state remain not measured until authenticated access exists. Publishing a page or listing it in a sitemap is not a substitute for measuring what happened afterward.

If you need a reusable way to preserve that evidence boundary, read the AI Agent Production Readiness Checklist or use the free browser-based verdict generator. Both separate PASS, FAIL, WAITING, and NOT_MEASURED instead of guessing.

Is your green deployment serving the wrong thing?

DrNoGreasy Studio builds and audits React, Cloudflare Workers, Vercel, mobile, AI agent, and automation systems with regression tests, production readbacks, and permission boundaries included.

Contact the studio, browse more developer resources, or start with the free readiness tool.