Engineering
client:only vs client:load — the directive that cost us a page
One Astro directive made an interactive page invisible to search engines. How to tell which you need, and how to check what crawlers actually see.
The difference between client:only vs client:load in Astro is whether the component is server-rendered at all. client:load renders HTML on the server and hydrates it in the browser. client:only skips the server entirely and renders nothing until JavaScript runs. We shipped an interactive demo page with the wrong one, and a crawler saw 76 words where a reader saw a full application.
What each directive actually does
client:load runs your component during the build (or the request), emits real HTML, sends it, then hydrates that markup into an interactive component. A crawler with no JavaScript sees the content. A user on a slow connection sees it before the bundle arrives.
client:only="react" sends a placeholder. The framework mounts on the client and builds the DOM from nothing. Before hydration, there is no content — because there was never a server render.
Both produce identical interactive results. They differ entirely in what exists before JavaScript executes.
Why we reached for the wrong one
The reasoning was plausible: the component measures the viewport, tracks window positions and reads a live clock. That felt like “no useful server render”, and client:only avoids any hydration mismatch worry.
The measurement said otherwise:
words in <main>: 76 # /demo
words in <main>: 1279 # /
contains "Priya", "awaiting approval": false
Seventy-six words on a page in the primary navigation, targeting real search intent. Every app name, every piece of demo content — invisible.
Switching one directive:
words in <main>: 272
contains "Priya", "awaiting approval": true
Same page, same behaviour for users. The only change was that the server now rendered it.
When client:only is genuinely required
It’s not a bad directive — it’s a narrow one. Use it when:
The library touches browser globals at import time. Some canvas, map and editor libraries reference window or document in module scope. They will throw during SSR, and no amount of guarding inside your component helps.
The initial render is inherently non-deterministic. If server and client cannot agree — a random seed, Date.now() rendered directly into markup, something read from localStorage — you get hydration mismatch warnings and, worse, a flash of replaced content.
There is genuinely nothing meaningful to render. A component whose entire output depends on a measured viewport size has no honest server render.
The test that decides it
Before choosing client:only, check three things about your component:
- Does any
window/documentaccess happen outsideuseEffectand event handlers? If it’s all inside, SSR is safe — those never run on the server. - Is the initial state deterministic? Fixed initial values are fine. A clock is fine if it renders empty until an effect fills it.
- Is there content worth seeing before hydration?
Ours passed all three, which is what made the fix a one-word change.
Two things you get back
Crawlability is the obvious one. Search engines do execute JavaScript, but not reliably, not immediately, and not with your budget in mind. Server-rendered content is indexed on the first pass.
Resilience is the one people forget. With client:load, our demo desktop still renders without JavaScript at all — not interactive, but readable. With client:only it was a blank box. That’s also what your users on a failed bundle load, a corporate proxy, or a flaky connection experience.
How to verify, not assume
Don’t trust the browser — it runs JavaScript. Fetch the raw HTML:
curl -s https://yoursite.com/page | grep -c "content you expect"
Or strip the tags and count the words that actually shipped. The number tells you immediately whether you have a rendering problem.
Worth doing across every route with an island, once. It takes minutes and it’s the only way to find a client:only that shouldn’t be there — the page looks perfect in every browser you test in.
The rule we adopted
Default to client:load. Reach for client:only when SSR actually fails, not when you suspect it might be pointless.
The cost of guessing wrong in one direction is a hydration warning you’ll notice immediately. The cost in the other direction is a page that quietly ranks for nothing, and nothing in your local development or your test suite will tell you.
See also: shipping 600 generated pages without tripping doorway detection.