Building in public
We built a demo Google couldn't see
Eleven interactive apps, a guided tour, thousands of lines of React — and a crawler that received 76 words. One directive was the difference.
We built a full product demo into the marketing site — eleven working apps, a window manager, a command palette, a guided tour that opens each app in sequence. It’s the best thing on the site. You can click through the whole thing without an account.
Then I checked what a crawler receives when it requests that page. Seventy-six words. A heading, a loading state, and nothing else.
How it happened
The demo is a React island in an Astro page. Astro makes you declare how each island hydrates, and the two options I was choosing between look almost identical:
client:load— render on the server, ship the HTML, hydrate in the browser.client:only— don’t render on the server at all; mount entirely client-side.
I’d used client:only. There was a reason: the demo touches window during setup, and client:only skips server rendering, which makes that class of problem disappear without having to think about it.
It’s the directive that makes the error go away, and it makes the content go away with it. On the page you’d never know — everything mounts in milliseconds and looks perfect. The only way to see the problem is to ask for the page the way a machine does.
What “76 words” means
For a page whose entire purpose is to show what the product does, having no server-rendered content is close to having no page.
Search engines do execute JavaScript, and eventually they may render it — but rendering is a separate, deferred, resource-limited pass, not a guarantee. Anything else that reads pages without a browser engine gets nothing at all: link previews, some crawlers, and increasingly the systems that assemble answers rather than lists of links.
So we’d built our most persuasive asset behind a door that only opens for browsers.
The two-step fix
Switching to client:load took one word and got us from 76 words to 272. The demo now server-renders its shell, and the markup exists whether or not the script runs. The distinction between those two directives is worth understanding properly, because it’s the difference between a page that exists and a page that only appears to.
That was the bug fix. It wasn’t the real fix.
272 words of server-rendered UI chrome is technically visible and substantively empty — app names, a dock, a window frame. It describes an interface, not a product. A crawler could see it and still learn nothing, and so could a person who landed there from a search result and didn’t feel like clicking around.
So the second step was writing actual content onto the page: a “what’s inside” section describing each of the eleven apps and what it does, five FAQs answering what people genuinely ask before trying a demo, and FAQPage structured data. That took it to 757 words — and unlike the first 272, they’re words that answer something.
The part I keep thinking about
The interesting failure here isn’t the directive. It’s that I verified the demo by using it, dozens of times, in the way a person uses it, and that verification was incapable of finding the problem.
Everything I checked was real: the apps worked, the tour ran, the layout held at every breakpoint. None of it touched the question of what the server sends, because using a browser is precisely the condition under which the bug is invisible.
The generalisation is that a test’s blind spot is usually its environment, not its assertions. Testing in a browser can’t find bugs whose trigger is not having a browser. It’s the same shape as an animation that only fails when JavaScript doesn’t run — the failing case is defined by the absence of the thing you’re testing with.
The check that finds it takes seconds:
curl -s https://example.com/page | sed 's/<[^>]*>/ /g' | wc -w
Fetch the page with no JavaScript engine, strip the tags, count the words. If the number is small, the page is small — regardless of how it looks to you.
What we do now
Three habits came out of this.
client:only needs a justification in the code review. It’s the right call for genuinely browser-only things — a canvas, an editor bound to the DOM. It’s the wrong default, and it’s seductive precisely because it silences errors.
Word count is a check, not an afterthought. Any page that matters gets the curl-and-count. It’s cheap enough that there’s no reason to skip it, and it catches the whole class of “renders beautifully, contains nothing.”
Interactive content still needs static content next to it. The demo is better than any description of the demo — for someone who clicks. The static section exists for everyone who doesn’t, which is most people and every machine.
What it was worth
The demo is now the second-best-performing page on the site, and it was invisible for weeks after being finished.
Nothing about it changed except that the page started containing what it was already showing. That’s the lesson in one sentence: if the only way to learn what your page says is to run it, most of the internet will never find out.
See also: why we let people break the demo on purpose.