your prerender says it worked. here's how to tell if it actually did
Eleven pages on one of our own sites shipped as HTTP 200 shells carrying the homepage title and nothing else — and our thin-content guard waved them through, because on a chrome-heavy route an empty page is bigger than a real one. Why byte counts can't detect an empty render, and the thirty-second check that can.
Our build was green. The prerender walked every route, wrote every file, and threw nothing. The sitemap came out complete.
Eleven of the URLs in it were empty.
Not 404. Not a 500. Not a warning buried in the log. Eleven pages on findbest.pro — /blog, /discover, /categories, /lists, /brands, /locations, /comparisons, /trends, /tier-lists, /ranked, /templates — each answering HTTP 200, each carrying the homepage’s <title>, and each containing exactly 1,074 characters of navigation and footer. That was the whole document. Header, nav, footer, closing tag. No headings, no posts, no lists, no error.
The eight-hundred-odd detail pages sitting underneath those hubs rendered perfectly.
If you check status codes — and most people only ever check status codes — that site looked flawless.
a byte count is not a content check
We had a guard for this. A thin-content check: render the page, strip the tags, count the characters, fail if the result came back under MIN_TEXT_CHARS = 500.
That threshold wasn’t plucked out of the air. It was calibrated, and the calibration was written down in a comment right next to it: a real page on that site measured at least 1,036 characters of text, and an empty data-driven page — a profile whose fetch never resolved — measured 75. Between 75 and 1,036 there’s a lot of daylight. 500 sits comfortably in the middle of it. Against the pages it was calibrated on, the guard was correct.
The hub routes are not data-driven pages. They’re chrome-heavy static routes: big nav, big footer, category rails, a fat link block at the bottom. When one of those renders nothing, you don’t get 75 characters. You get 1,074 — all furniture, no room.
1,074 is above the 500-character floor. It is also above the 1,036-character number the comment recorded as the minimum for a healthy page. Read that twice, because it’s the entire bug: on those routes, a completely empty render was longer than the guard’s own definition of a good one. There was no threshold available. Length did not separate the two populations at all. Raising it to 1,200 would have started failing legitimate pages while still passing blank ones.
So, first lesson, and it generalises well past our stack: a length threshold cannot detect an empty render on a chrome-heavy route. Your header and footer are content as far as wc -c is concerned. The more design you put in your shell, the less signal length carries. Assert on something only a rendered page can produce — its own <title>, its own <h1>, an element that route is uniquely responsible for creating. Not its size.
the flag that turned the check off
There was a second guard, and it would have caught this in about four milliseconds: assert that the rendered page’s <title> is the page’s own title and not the shell’s.
It was switched off on exactly the routes that broke. One flag:
requireTitle: !isStatic
Static routes — the hubs — skipped the title assertion. Detail routes got the assertion and a retry ladder that re-rendered on failure. Which is precisely why eight hundred detail pages were fine and eleven hubs were not. The routes where the check ran had no bugs. The routes where it was disabled had eleven.
I don’t think whoever wrote that line was careless. There’s always a reason: static routes are “simple”, they don’t fetch anything, they render instantly, the assertion went flaky during some earlier refactor. Every disabled check has a plausible story behind it at the moment it’s disabled. The story ages badly and the flag doesn’t.
So: be suspicious of any flag that turns a safety check off for a subset of routes. Not because the reasoning was wrong, but because you’ve just created a region of the codebase with less coverage than the rest, and defects find low ground the way water does. Grep your build config for skip, unless, except, only, !is — that’s a map of where your next incident happens.
The fix was to stop exempting anything and assert on each page’s own title everywhere. All eleven recovered on the next build, and we confirmed them live after deploy rather than trusting dist/. /blog was one of the eleven; it’s the page you land on now at findbest.pro/blog.
http 200 proves nothing
The reason this survived is that every casual check reported success.
An SPA with a catch-all route answers 200 for every URL you ask it for, including URLs that don’t exist. Your host does the same thing — a static host with an SPA fallback serves index.html for anything it can’t match. A status code tells you a file came back. It tells you nothing whatsoever about whether that file contains your page.
If your monitoring is a green uptime check, your prerender can be completely broken and your dashboard will stay green indefinitely. Read the title. That’s the check.
hubs and details fail independently
Last one, and it’s the one that actually costs you.
Roughly 836 working pages and 11 broken ones sounds like a 98.7% pass rate and a rounding error. It isn’t, because the two sets do different jobs.
Detail pages are destinations. Hub pages are the crawl paths that lead to them — the category index, the location list, the all-brands page. When hubs render empty, every internal link on them vanishes. The detail pages still exist and still work, but the structure that told a crawler how they relate, which ones matter, and where to go next is gone. Your internal link graph goes invisible.
And here’s the cruel part: the sitemap keeps working. Every URL is still listed, still returns 200, still gets crawled on that basis. So the index doesn’t collapse, coverage reports stay clean, and nothing flashes red. The sitemap holds the pages up while the link graph underneath them has quietly gone flat. That’s a slow leak no alert will ever fire for.
Check hubs separately from details. They fail separately.
the check you can run in thirty seconds
Take five or six hub URLs and ask two questions per page: what’s the title, and how much text is there.
for u in / /blog /categories /brands /locations /lists; do
html=$(curl -s "https://yoursite.com$u")
title=$(printf '%s' "$html" | tr -d '\n' | grep -o '<title>[^<]*' | head -1)
chars=$(printf '%s' "$html" | tr -d '\n' \
| sed -e 's|<script[^<]*</script>| |g' -e 's|<[^>]*>| |g' \
| tr -s ' ' | wc -c)
printf '%-14s %7s %s\n' "$u" "$chars" "${title#<title>}"
done
Two failure signatures, both obvious the moment the table is in front of you:
- Every row shows the same title. That’s the shell. Either the prerender didn’t run for those routes, or it snapshotted the page before the router swapped the real view in.
- The character counts collapse into two clusters. One tight cluster is your chrome. Anything sitting in it is empty, whatever the absolute number happens to be. This is why relative comparison beats an absolute threshold — you don’t need to know that 1,074 is bad, you only need to notice that eleven pages agree on it exactly. Real pages never agree exactly.
Run it against production, not against dist/. What your build wrote and what your CDN serves are two separate claims, and only one of them is what Google sees.
the honest version
We prerender everything meant to be found — it’s a rule, not a preference, and it’s baked into how we build every browser app in the webapps portfolio. CreateQRCodes runs the same discipline on its own pipeline; its blog is prerendered for the same reason.
This still got past us. In a pipeline that already had a guard written specifically to prevent it. Because the guard measured the wrong property, and the check that measured the right one had been politely excused from the routes that needed it most.
Assert on the title. Distrust the flag that skips the check. And never let a 200 convince you the page is there.
Think we're wrong? This is an opinion piece and we'd genuinely like the argument. A person reads every email that comes in, within 24 hours — that's rule 04 and we don't get to skip it because you disagreed with us.