One line that read the query string sat under the only loading boundary in the app, and it pushed every page into rendering in the browser only. Twelve words of markup left our server. Nothing in the build said so.
Until this week, every page on canaryvaults.com served an empty body. Not a slow page, not a broken-looking one. The HTML that left our server contained the doctype, the head, and a single link reading Skip to main content. Twelve words in total.
We only found it by fetching the raw HTML instead of opening the site in a browser. In a browser everything looked correct, because a browser runs JavaScript. Every crawler that does not (most social unfurl bots, a good share of the LLM crawlers, and anyone whose scripts fail to load) received a blank document. Around forty URLs sat in our sitemap with no body text behind any of them.
The cause was one line that read the page's query string. Our web framework treats reading it during render as a reason to stop rendering on the server, and it stops at the nearest loading boundary above the code that asked. Ours sat in the component that decides which shell a route gets, and the only boundary above that component was the one in the root layout, wrapping every page in the application. So a single effect that reads a ?ref= referral code from the query string pushed the entire site into rendering in the browser.
Nothing told us. The build succeeded. Type checking passed. Lint passed. Every page rendered correctly in a browser and in a preview deploy. The framework documents this behaviour, and knowing that a thing is documented is not the same as noticing that it applies to you. There was no error, no warning in the build output, and no visible symptom anywhere a person normally looks.
The fix was to give that line its own boundary. It now lives in a small component whose only job is to store the referral code, wrapped in a boundary of its own. The bail-out still happens; it just applies to a component that renders nothing. Everything around it prerenders.
The same pricing page went from twelve visible words of markup to 934, with eight headings and every plan name and price present in the server HTML. The bail-out marker is still in the page source, which is the fix working rather than failing: it now sits in the first few hundred bytes, and 931 of those 934 words come after it.
There was a sting in the tail. Turning on server rendering for a tree that has never been server-rendered means any code touching browser globals during render starts running on the server. We audited the whole shell before pushing and found no crashes, but we did find our own footer calling new Date().getFullYear() in its render body. Harmless while everything was client-rendered; once prerendered, the year freezes into the static HTML at build time, and a build spanning New Year would serve one year in the markup while the client rendered another. We removed the year rather than suppressing the warning.
Whatever your site is built with, the check takes thirty seconds and needs no tooling: curl your own homepage and read what comes back. If the body holds nothing but a skip link and a pile of script tags, you have this bug. The browser shows you the page you think you shipped. curl shows you the page you actually shipped.
This article is about a shipped surface: All product surfaces. Integration details live in the docs.
Continue reading
More notes from the CanaryVaults team.
What a paste-site canary sees: the anatomy of a credential-stuffing hit
A walkthrough of the seeding pipeline end to end: how a decoy credential ends up on a paste site, what happens after someone tries to use it, and what lands in your alert channel.
Detection designNobody detected the wiki incident. Someone reconstructed it.
Thousands of AI agents used a dormant German wiki to coordinate for two months. Nothing detected it. Volunteers reconstructed it from the edit history.
EngineeringIt worked, it said so, and nothing happened
A contact form returned 201 and showed a green confirmation every time. Nobody was ever notified. We went looking for more of these and found about thirty, all with the same shape.