When people move to Astro from React or Next.js, they reach for a framework island on the first interactive element they build. A dropdown becomes a React component with client:load. The reason to not always have islands is that hydration is a expensive. This post explains why and gives you an order of options to work through before you hand a component to the browser.
Prerequisites
You’ll need:
- Basic familiarity with Astro components
- Node.js 22 or later
Why hydration is expensive
A client directive tells Astro to send a UI framework component to the browser and hydrate it there and:
- It runs your component twice: The server already rendered the HTML. Hydration downloads the framework runtime and the component, then runs the component again in the browser to rebuild its state and attach event listeners to markup that already exists.
- It blocks the main thread: Hydration is synchronous work that competes with the user’s first clicks and taps. This is the work that shows up as Interaction to Next Paint (INP) in the field.
- It scales with the component tree, not with the interactivity: A large island with one button still hydrates the entire tree.
Now you may think of client:idle and client:visible but they only defer the runtime and the second render. They do not remove the expensive operations hydration carries.
Components are not islands
A common mix-up is treating every component as an island. Splitting a page into <Header />, <Posts />, and <Footer /> is componentization. Those are .astro files, they render to HTML on the server, and they carry no JavaScript.
An island is a narrower thing: a UI framework component (React, Vue, Svelte, and so on) that carries a client:* directive. A .astro component cannot take a client directive since it never hydrates in the first place.
An order of options before you hydrate
Before adding a client directive, work down this list:
- Static HTML: Can this render on the server and never change per user?
- Data resolved at build time: Does the value only need to be current as of the last deploy? Fetch it during the build and bake it into the HTML.
- A server island: Does the value need to be fresh per request, such as a cart count or a signed-in name? Render it on the server with
server:deferand keep the rest of the page cacheable. - A script with progressive enhancement: Is this a one-shot DOM behavior like a toggle, a dialog, or a tab switch? Render the working markup on the server and attach a small script.
- A hydrated island: Does this need ongoing client-side state that many parts of the UI derive from and react to? Now a framework is absolutely required, and you want to hydrate the smallest component that holds that state.
Let’s see few approaches as in the list in detail.
Data resolved at build time
If a value only needs to be current as of your last deploy, resolve it during the build. A per-post view counter is the classic case people build an island for.
The island way: A framework component that fetches the number in the browser after it hydrates.
---import ViewCount from '../components/ViewCount.jsx'const { slug } = Astro.params---
<ViewCount slug={slug} client:load />import { useEffect, useState } from 'react'
export default function ViewCount({ slug }) { const [views, setViews] = useState(null)
useEffect(() => { fetch(`/api/views?slug=${slug}`) .then((res) => res.json()) .then((data) => setViews(data.views)) }, [slug])
return <span>{views === null ? 'Loading...' : `${views} views`}</span>}Yes, we’re all used to using React for this and it might be the easier one to fallback on. But for the entire website that has few dynamic components, does it even make sense to load the entire React runtime and the component, hydrate on load, then makes a request from every visitor’s browser to fetch the views?
The build-time way: Fetch during the build and bake the number into the HTML.
---// Runs at build time for a static page. The number is part of the HTML.import { getViews } from '../lib/analytics'
const { slug } = Astro.propsconst views = await getViews(slug)---
<span>{views} views</span>Now, you can rebuild this on a schedule, such as a daily deploy hook, and the count stays current enough for most content.
What changes between the two:
- JavaScript: the React runtime plus the component vs None.
- Network on view: one request from every visitor vs None.
- First paint: a loading state that resolves late vs the number already in the HTML.
The one thing you give up though is freshness. The build-time number is only as current as your last build. For a view count that is fine, and you skip the framework entirely.
A server island for per-request data
Sometimes the value has to be correct on every request, such as a cart count or a signed-in name. Build-time baking cannot help there, so the reflex is a client component that fetches on mount, which puts you right back to shipping a runtime and a loading state.
A server island gives you fresh data without any of that. You render the component on the server, deferred, so the static page around it stays cacheable.
---import { getCartCount } from '../lib/cart'
const count = await getCartCount(Astro.request)---
<span class="cart-count">{count}</span>---import CartCount from '../components/CartCount.astro'---
<CartCount server:defer> <span slot="fallback">0</span></CartCount>Astro renders the static shell first, shows the fallback, then fills the island from the server. The shell can be cached hard, the count is correct per request, and the browser receives HTML with no framework runtime attached. Compared to the client fetch, you keep the fresh data and drop the JavaScript.
Progressive enhancement with a script
For a one-shot DOM behavior like a toggle or a tab switch, you do not need a framework’s reactivity. You need an event listener. Astro processes <script> tags for you, bundling them and adding them as modules, so a script can run in the browser without pulling in React or Svelte.
The island way: Tabs as a React component, hydrated on load.
import { useState } from 'react'
const tabs = ['Overview', 'Pricing', 'FAQ']
export default function Tabs() { const [active, setActive] = useState(0)
return ( <div> <div role="tablist"> {tabs.map((label, index) => ( <button key={label} role="tab" aria-selected={index === active} onClick={() => setActive(index)} > {label} </button> ))} </div> {tabs.map((label, index) => ( <div key={label} role="tabpanel" hidden={index !== active}> <p>{label} content</p> </div> ))} </div> )}Used with <Tabs client:load />, this ships the React runtime and the component, then hydrates the whole thing to handle one piece of state: which tab is open.
The script way: The same tabs as server-rendered HTML with one script attached.
---const tabs = ['Overview', 'Pricing', 'FAQ']---
<div data-tabs> <div role="tablist"> { tabs.map((label, index) => ( <button role="tab" id={`tab-${index}`} aria-controls={`panel-${index}`} aria-selected={index === 0 ? 'true' : 'false'} > {label} </button> )) } </div>
{ tabs.map((label, index) => ( <div role="tabpanel" id={`panel-${index}`} aria-labelledby={`tab-${index}`} hidden={index !== 0} > <p>{label} content</p> </div> )) }</div>
<script> function setupTabs() { document.querySelectorAll('[data-tabs]').forEach((root) => { if (root.dataset.ready) return root.dataset.ready = 'true'
const buttons = root.querySelectorAll('[role="tab"]') buttons.forEach((button) => { button.addEventListener('click', () => { buttons.forEach((other) => { const selected = other === button other.setAttribute('aria-selected', String(selected)) const panelId = other.getAttribute('aria-controls') const panel = panelId ? document.getElementById(panelId) : null if (panel) panel.hidden = !selected }) }) }) }) }
setupTabs() document.addEventListener('astro:page-load', setupTabs)</script>What changes between the two:
- JavaScript: the React runtime plus the component vs a few lines of bundled script.
- Second render: the island re-runs the component in the browser to attach
onClickvs the script attaching listeners to markup. - Before JavaScript loads: the first tab is visible and the ARIA roles are set in both, but the script version keeps working as plain content if the script fails.
Since Astro’s bundled module scripts run once, under client-side routing (the <ClientRouter />) a top-level script does not re-run after navigation and its listeners stop working. Binding to astro:page-load re-runs the setup on every navigation, and the data-ready guard stops it from binding the same element twice. This pattern covers most of what people build islands for: accordions, dropdowns, dialogs, copy buttons, and forms.
When hydration is the right call
Skipping islands should be the default but when the interaction has real client-side state that many parts of the UI derive from and react to over time, you would want to use islands:
- A form where fields depend on each other and the UI updates as the user types.
- A view that receives updates over a WebSocket and re-renders often.
- A canvas, drag-and-drop, or charting surface with heavy internal state.
- An existing third-party React or Vue widget you would rather drop in than rewrite.
When you do hydrate, do it with:
- Hydrate the leaf, not the tree: Put the directive on the smallest component that holds the state. Keep the static parts around it as
.astro. - Defer when you can:
client:visibleandclient:idlepush the cost past the first paint for anything below the fold. - Watch what you pass as props: Astro serializes island props into the HTML so the client can rehydrate with the same values. Passing a large object or array inflates the page for every visitor. Pass ids and primitives, and fetch the rest inside the island or a server island.
- Avoid
client:onlyfor content: It skips server rendering entirely, so the component is blank in the initial HTML. That is a problem for anything you do want indexed.
Conclusion
Astro ships no JavaScript by default, and hydration is the point where you can opt back into the cost of a framework in the browser. That is expensive: second render, main-thread time, and a payload that grows with the size of the component.
If you have any questions or comments, feel free to reach out to me on Twitter.