Search Console told us we were ranking. In Singapore, average position 1. Across three months: 42 impressions, 0 clicks. Not a low click-through rate — none.
Twelve days and one engineering pass later, the same property read 2 clicks / 103 impressions / 1.9% CTR / average position 62.7. Inside that, two rows that shouldn't be able to coexist:
| URL | Clicks / impressions | Average position |
|---|---|---|
https://www.erdonline.com/ |
1 / 90 | 61.3 |
https://www.erdonline.com/compare |
1 / 8 | ~2–3 |
One page buried on page six for the queries people actually type. Another sitting at position two for a query almost nobody types. The instinct in that moment is to write more content, because "more content" is the answer every SEO article gives. It was the wrong read, and chasing it would have cost us a month.
The real problem was that our site had one identity. It had eight marketing URLs, a sitemap, hreflang tags, and JSON-LD — and every single one of those URLs handed Google the same first byte.
The diagnosis takes one command
We build a client-rendered UmiJS app hosted on Cloudflare Pages. Before diagnosing anything clever, curl what the crawler gets:
curl -sL https://www.erdonline.com/catalog | grep -E '<title>|rel="canonical"'
The answer, for months, was the homepage's <title> and <link rel="canonical" href="https://www.erdonline.com/">. Same for /compare. Same for /en. Same for /demo. And then this one:
curl -sI https://www.erdonline.com/__seo_health_nonexistent_path__
# HTTP/2 200
A URL that has never existed, returning 200 with the homepage in the body. That is a soft 404, and once you have one, you have infinitely many. Googlebot doesn't see a site with eight pages plus some noise. It sees one page reachable at an unbounded number of addresses, which is the exact shape of a low-quality site, and it responds by picking one URL to keep and quietly discarding the rest.
Rank was never the bottleneck. Being a distinct page was.
Two platform defaults, both documented, both easy to miss
Neither cause was exotic. Both were things we had written ourselves and then stopped looking at.
A catch-all rewrite. public/_redirects contained /* /index.html 200. This is the line every SPA tutorial tells you to add so client-side routing survives a hard refresh, and it is correct for /project/1234, a page behind a login that has no business being indexed. It is destructive for /compare, a page whose entire job is to be indexed under its own title.
No top-level 404.html. Cloudflare Pages has a documented rule here that catches people: if there is no 404.html at the root of your build output, Pages assumes you are a single-page app and serves 200 + index.html for every path that doesn't match a static file. Ship a 404.html and that automatic SPA mode turns off — unmatched paths get a real HTTP 404. One file, and infinite soft 404s stop.
Three smaller footguns cost us a deploy each, and they are the kind of thing nobody writes down:
- A rewrite target of
/index.htmldoesn't work. Cloudflare 308-redirects*.htmlto its extension-less form, so a rewrite meant to be invisible becomes a redirect to/. - The splat in
/catalog/* → / 200matches/catalog/with an empty segment, so the catch-all quietly shadowed the very list-page shell we had just generated. It has to be/catalog/:id. - We first sent unknown template IDs to a placeholder shell at
/catalog/_item. Because that path is a directory, Cloudflare 308'd/catalog/some-bad-idonto/catalog/_item/— we had invented a brand-new crawlable junk URL while trying to clean up crawlable junk URLs. The fix was to rewrite unknown IDs to/catalog/(already slashed, so nothing to 308 onto) and 301_itemaway.
Every one of those is a redirect rule that is almost right. Which is the point: this class of bug produces a perfectly working website for humans and an unindexable one for crawlers, and no amount of reading your React code will surface it.
Four artifacts, one source of truth
The fix was to prerender a per-path shell at build time — dist/catalog/index.html, dist/compare/index.html, dist/en/compare/index.html, and so on — each carrying its own <title>, canonical, hreflang set, and JSON-LD, then remove those exact paths from the redirect file so the static file wins.
That's the obvious half. The half worth stealing is what we did to stop it from rotting.
Adding one public route requires four artifacts to agree:
- the list of URLs in
sitemap.xml - the set of paths that get a prerendered shell
- the Cloudflare
_redirectsrules for everything that doesn't - the nginx
mapfor self-hosted Docker deployments
Four files, maintained by hand, in a repo where the person adding a route is thinking about React Router and nothing else. They will drift. Ours did.
So they all became derived values exported from one module, frontend/scripts/seo-config.mjs: SITEMAP_PATHS, PRERENDER_PAGES, CF_SPA_REDIRECT_RULES, NGINX_SPA_URI_REGEXES, plus marketingHreflang() and jsonLdForPage(). The build script consumes it and writes sitemap.xml, robots.txt, _redirects, _headers, 404.html, and every shell in one pass. Adding a route is one entry in one array. Drift between the sitemap and the redirects is no longer a mistake you can make.
That file is also where the hard-won knowledge lives, as comments next to the rules they explain — including the one that reads, in effect, do not use `/catalog/` here, we tried, here is what Cloudflare does*. Comments that record a footgun earn their keep. Comments that narrate what the next line does do not.
Two bugs that only exist in single-page apps
With the shells shipped, two failures showed up that have no equivalent on a server-rendered site.
Hydration fought the shell. /catalog/demo-authz prerendered correctly, then the client booted and CatalogLayout called usePageSeo(catalog.seo.*) unconditionally — so the detail page's title was replaced, a few hundred milliseconds later, by the list page's title. The prerender was right and the app overwrote it. If you prerender per-path metadata in a CSR app, your client-side SEO hook needs to know which page it is on, or you have built two systems that disagree about <title>. Now the list applies list metadata, and a detail page waits for its API response and writes {name} — ER diagram templates with a canonical pointing at /catalog/:id.
Every page claimed to be the app. Our JSON-LD generator was parameterizing url and nothing else, so @type stayed WebApplication everywhere. Eight distinct pages each announcing themselves as the application living at the site root — structured data actively contradicting the canonical tags we had just fixed. It's now typed per path: / is a WebApplication, the template list is a CollectionPage, an official template detail is an ItemPage, everything else is a WebPage.
The title problem was a promise problem
Position 1 with zero clicks is not a ranking failure. It is a snippet that doesn't sound like the thing the searcher wanted.
Ours was brand-first, and the searcher's job-to-be-done was not "learn about a brand." It was "draw an ER diagram, in a browser, now." The queries said so plainly: erd online, erd diagram online, make|create|draw erd online. So the <title> became job-first — Draw ER Diagram Online — Free Editor | ERD Online — and the description covers the intent cluster in one honest sentence: draw, ERD editor and maker, entity-relationship models.
Three things we deliberately did not do, now written into docs/landing.md as rules rather than left to judgment:
- No "file viewer." It's a plausible, high-volume phrase. We support ERD/PdMan/DBML import; we do not ship a dedicated file viewer. Ranking for a query you can't satisfy buys one visit and one bounce.
- No naming Google Draw. Our comparison page names draw.io, because we have a real technical claim there — a line in draw.io is a line, while a relationship in ERD Online carries foreign-key semantics. We won't name a product we haven't actually compared.
- No stuffing the non-English queries we could see in the query report. Impressions from an audience we don't serve in their language are not a win.
The H1 stayed "Git + Figma for database design." The <title> and the H1 now disagree, on purpose. The <title> answers a query; the H1 answers "what is this, and why should I care" for someone who has already arrived. Collapsing both into keywords costs you the second job and reads like a landing page from 2011.
Make the crawler's view a test
The failure mode here is silent, so the only durable fix is an assertion. scripts/seo-index-health.mjs runs daily in CI and checks, against the live site:
- Each of
/,/demo,/catalog,/compare,/docs/,/docs/roadmap: HTTP 200,text/html, non-empty<title>, non-empty meta description, a canonical, and nonoindex. -
/robots.txt: 200, nottext/html(a robots.txt served as HTML means the SPA fallback ate it), containsUser-agent: *, contains an absoluteSitemap:URL, and does not containcontent-signal:— that last string is how you detect that your platform's injected default robots.txt is still winning over yours. -
/__seo_health_nonexistent_path__: must return 404. This is the soft-404 regression, pinned as one assertion. -
/compare,/catalog,/demo,/login: must still return 200, because the obvious way to fix soft 404s is to break your real routes. -
sitemap.xml: parses, has at least one<loc>, and the first 20 URLs are reachable.
At build time, yarn test:seo-static runs the same config without a full build, and the production smoke suite has a test named, verbatim, crawler first HTML uses path canonical (not homepage). The bug we shipped for months now has a name in a test file.
Two more things from the same pass, both non-obvious:
Cache headers alone did nothing. The homepage was returning CF-Cache-Status: DYNAMIC with TTFB around 1.6s (our docs site, 3.8s) while Search Console flagged impressions sliding. We set Cache-Control: public, max-age=0, s-maxage=600, must-revalidate on the HTML shells. Nothing changed — Cloudflare Pages treats text/html as dynamic by default, and you must also create a Cache Rule in the dashboard making HTML eligible for cache. That dependency is now a comment in _headers, because a header file that silently does nothing is worse than no header file.
Pick one slash form and make all four places agree. Our docs site canonical said no trailing slash, the sitemap <loc> said no trailing slash, the host 308'd to a trailing slash, and legacy redirects pointed at the non-slash form — so an old URL took a 301 and then a 308 to arrive. trailingSlash: true, redirect targets slashed, sitemap <loc> slashed, /search and blog tag/archive pages dropped from the sitemap. One hop.
What we stopped doing
Clicking "Request indexing." We tried it on /catalog twice and got Search Console's reCAPTCHA error both times; nothing entered the queue. Manual submission is a nudge, not a mechanism, and treating it as progress hides the fact that your artifacts are still wrong. Fix the artifact, submit the sitemap, then wait.
We also decided, in advance, what would make us change strategy. Our locale routing ADR ships English marketing pages as CSR with a sitemap and hreflang, and states the trigger explicitly: if /en is not indexed 90 days after launch, we build static export for the marketing routes. Writing the trigger down before you need it is what stops "should we go SSR?" from being re-litigated every two weeks on vibes.
The same ADR is worth reading for what it refused to do. Full-site /en/* routing would have touched 60 route entries, 76 history.push call sites across 39 files, 236 page.goto calls in the E2E suite, 16 backend files containing frontend URLs, and the immutable /s/:token share links already pasted into other people's chat logs. The pages that would have gained from it — /project/**, /design/**, /home — are all behind a login and have no search value whatsoever. Marketing pages got the prefix. Nothing else did. Internal link points touched: about 21.
The scoreboard, honestly
As of today: /catalog, /compare, and /en/compare are indexed. /en/catalog is "discovered — currently not indexed." The docs site's English sitemap was accepted and 66 pages were discovered. Total organic clicks over three months: 2.
This is not a traffic case study. Indexing moves on a timescale of weeks, and it would be dishonest to attach a hockey stick to a twelve-day engineering pass. What we can report is that the crawler's view of the site is now correct, asserted daily, and cheap to extend — which is the part that had to be true before any content investment could compound.
The checklist
If you run a client-rendered site, these are worth thirty minutes:
-
curl -sL <your-non-homepage-url> | grep -E '<title>|canonical'. If you see your homepage, stop reading and fix that. -
curl -sI <your-domain>/__definitely_not_a_real_path__. If it's 200, you have unbounded soft 404s. On Cloudflare Pages, ship a root404.html. - Delete the catch-all rewrite. Enumerate the SPA paths that genuinely need a 200, and let everything else 404.
- Make sitemap, prerendered shells, host rewrites, and self-host rewrites derive from one module. Adding a route should be one line.
- Check that hydration isn't overwriting per-path metadata your build just wrote.
- Check
@typein your JSON-LD is actually per-page, not the homepage type with a swappedurl. - Pick one trailing-slash form. Canonical, sitemap
<loc>, host redirect, and legacy redirects must all agree. - Turn every one of the above into a daily assertion against production. This bug class is silent by construction.
What we're building
ERD Online is an open-source (MIT) database design tool: Git + Figma for database design. Versions and collaboration are the moat, and the projectJSON format is open so humans and AI agents read and write the same source of truth — an agent can call create_version through MCP, and a human still reviews the diff in the designer. It is not ChatSQL; it will not invent a diagram from a sentence.
Open a real ER diagram, read-only, no signup: https://www.erdonline.com/demo
Source, issues, and the SEO scripts described above: https://github.com/erdonline/erdonline
The config module is frontend/scripts/seo-config.mjs, the health check is scripts/seo-index-health.mjs. Both are short. Steal them.
Top comments (0)