One call to useSearchParams() sat inside the root layout's only Suspense boundary, and deopted the entire application to client-side rendering. 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 React hook. In the Next.js App Router, calling useSearchParams() in a client component forces the nearest Suspense boundary above it to bail out of prerendering. Ours sat in the component that decides which shell a route gets, and the only Suspense 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 deopted the entire site to client-side rendering.
Nothing told us. The build succeeded. Type checking passed. Lint passed. Every page rendered correctly in a browser and in a preview deploy. Next.js 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 the hook its own boundary. It now lives in a component whose entire job is to store the referral code and return null, wrapped in its own Suspense. 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.
If you run a Next.js App Router site, 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 in the moments after someone tries to use it, and what lands in your alert channel.
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.
PostmortemWe were selling a proof that had never run
Several pages described CanaryAudit records as anchored on-chain. Not one record ever had been, and the code that would have done it was not in the deployed image. Here is what happened, and what we decided instead.