Astro SSR with islands: when to hydrate a component and when to leave it static
Every time we migrate a project to Astro, teams coming from plain React or Vue ask the same first question: "where do I put client:load?" The correct answer is almost always "nowhere, not yet," and that answer tends to catch people off guard. Astro flips the default assumption of SPA-oriented frameworks: an .astro component, or a React/Vue/Svelte component imported inside an .astro page, renders on the server to plain HTML and ships zero bytes of JavaScript to the browser unless told otherwise. Islands architecture isn't an optimization you switch on later; it's the default behavior, and the real work is deciding which components break that rule, and why.
What an island is, and what hydration actually means
In Astro, every interactive component that runs in the browser is an "island": a fragment of HTML served from the server that, at some point after the initial load, gets hydrated with the framework that generated it (React, Vue, Svelte, Solid) so it becomes interactive. The rest of the page, layout, copy, images, any plain .astro component, never hydrates, because it never had JavaScript attached to it in the first place. It's static HTML served as-is, with no framework runtime executing in the browser.
That's different from what happens in a classic SSR framework like Next.js pages mode, or a fully hydrated SPA, where the entire page hydrates as a single unit even when 90% of the content is static text that never changes. In Astro, hydration is a per-component decision, not a page-wide one, and that granularity is what lets a landing page with a contact form ship JavaScript only for the form, not for the hero section, the footer, or the testimonials block.
The client:* directives
A framework component imported into an .astro file doesn't hydrate on its own. With no directive attached, Astro renders it to HTML at build time or request time and discards the interactive component: what reaches the browser is static markup with no <script> tied to it.
---
import Cart from '../components/Cart.jsx';
---
<Cart items={items} />
This Cart renders once on the server and stays frozen: the "add" or "remove" buttons won't respond to any click, because there's no React JavaScript running on that page. To make it hydrate, you have to say so explicitly:
<Cart items={items} client:load />
client:load hydrates the component as soon as the page's HTML finishes loading, with the same priority as a regular <script> without defer. It's the right directive for whatever the user needs interactive from the first instant: a shopping cart visible above the fold, a login form, a navigation menu with state.
client:idle delays hydration until the browser reports an idle moment, using requestIdleCallback with a short setTimeout fallback in browsers that don't support it. It makes sense for interactive components that are on the page but aren't the user's immediate priority: a chat widget, a language selector, a modal that opens on a later click.
<ChatWidget client:idle />
client:visible uses an IntersectionObserver and doesn't hydrate anything until the component enters the viewport. It's the directive with the biggest impact on long pages: a related-products carousel at the bottom of a product page, a comments section, an embedded map. If the user never scrolls that far, that JavaScript is never downloaded or executed.
<Comments postId={post.id} client:visible />
There's also client:media (hydrates only when a media query matches, useful for components that only make sense on mobile) and client:only="react" (skips server rendering entirely and renders only on the client, needed for components that depend on browser APIs like window or localStorage and would break SSR otherwise).
The mistake we see most often: hydrating without needing to
The common mistake isn't picking the wrong directive, it's adding client:load to a component that never needed to run in the browser at all. It shows up in two specific scenarios most often.
The first is porting React components straight over from a previous project, carrying the habit that "every React component needs to hydrate to look right." A product card that only shows an image, a price, and a link doesn't need any client-side JavaScript: it's content, not interaction. Wrapping it in client:load just because it's written in JSX inflates the bundle for no real gain, and on a listing page with thirty cards, those thirty components hydrating redundantly are the single most common reason an Astro migration doesn't deliver the performance improvement everyone expected.
The second is a component that does have an interactive part, but that part is small relative to the rest of the component. A blog post with a "like" button at the bottom doesn't need the entire post to be a hydrated React component; it needs the button to be one. The fix isn't "hydrate the whole block", it's extracting the button into its own small component and leaving the rest as static Astro markup:
---
import LikeButton from '../components/LikeButton.jsx';
---
<article>
<h1>{post.title}</h1>
<div set:html={post.contentHtml} />
<LikeButton postId={post.id} client:visible />
</article>
This pattern, isolating the real interactivity into the smallest possible component and leaving everything else as static markup, is the difference between an Astro project that actually cuts down the JavaScript shipped to the client and one that ends up looking like a traditional SPA with extra steps.
Serialized props and a cost that doesn't show up in the code
There's an additional cost that doesn't show up when you look at the directive, but does when you look at the props passed into the hydrated component. Any prop a client:* component receives has to be serialized to JSON and embedded in the page's HTML so the client-side framework can rebuild the component with the same initial state. Passing an array of a thousand products as a prop to a client:load component doesn't just hydrate unnecessary JavaScript, it also embeds those thousand serialized products in the page's HTML, duplicating data that was already there in rendered markup form.
The usual fix is to have the hydrated component receive only the minimum it needs for its own logic, an id, a small initial state, and have it fetch anything else itself, instead of receiving the entire dataset through props that was already used to render the static HTML around it.
How we actually decide
Before adding any client:* directive, the question we ask is literal: what browser event does this component need to handle that static HTML can't handle on its own? If the answer is "none," the component stays without a directive, rendered as plain HTML. If the answer involves a click, an input, a scroll that triggers a stateful animation, or a websocket subscription, then it does need to hydrate, and the directive is chosen based on when the user actually needs that interaction available: immediately (client:load), once the page has conceptually settled (client:idle), or only if they scroll to it (client:visible). Applied consistently across a whole project, the result is a page that can have twenty framework components written in the source code and ship active JavaScript for two or three of them in the real browser.