Client Directives
Turning a component into an island
Section titled “Turning a component into an island”A framework component imported into an .astro file renders to static HTML — no interactivity — until you add a client directive. The directive tells Astro two things: hydrate this component on the client, and when to do it.
---import Counter from '../components/Counter.jsx';---<Counter /> <!-- static HTML, no JS ships --><Counter client:load /> <!-- an island: hydrates immediately -->The “when” is the whole game. Hydrating earlier makes a component interactive sooner but costs upfront JavaScript and main-thread time; hydrating later keeps the page lean but delays interactivity. Each directive is a different point on that trade-off.
The five directives
Section titled “The five directives”client:load— hydrate immediately, as soon as the page loads. Use for above-the-fold controls that must work the instant the page is usable: a header search, a critical toggle. The most eager, most expensive option.client:idle— hydrate when the browser is idle (viarequestIdleCallback), after the main work of loading is done. Good for interactivity that’s important but not instant — it waits for a quiet moment.client:visible— hydrate when the component scrolls into view (viaIntersectionObserver). The best default for anything below the fold: a comments widget, a footer newsletter form, a carousel far down the page. Its JavaScript never loads if the user never scrolls there.client:media— hydrate only when a CSS media query matches, e.g.client:media="(max-width: 50em)". Use for interactivity that only exists at some breakpoints — a mobile-only hamburger menu that’s pointless on desktop.client:only="react"— skip server rendering entirely and render only on the client. You must name the framework ("react","vue","svelte", …) because Astro won’t render it on the server to detect it. Use for components that can’t render on the server at all — ones that touchwindow,localStorage, or a browser-only library on first render.
flowchart LR start["page loads"] --> load["client:load — hydrate now"] load --> idle["client:idle — when browser is idle"] idle --> visible["client:visible — when scrolled into view"] visible --> media["client:media — when a breakpoint matches"] only["client:only — skip server render, client only"]
The real mechanism: <astro-island>
Section titled “The real mechanism: <astro-island>”A client directive isn’t a build-time-only concept — it compiles into a real custom element that ships in the HTML. Building a page with <Counter start={0} client:visible={{ rootMargin: '200px' }} /> (Preact) and reading the actual output:
<astro-island uid="Z22rHo8" data-preact-island-id="1" component-url="/_astro/Counter.CzecfPIK.js" component-export="default" renderer-url="/_astro/client.N277o4ni.js" props="{"start":[0,0]}" ssr client="visible" opts="{"name":"Counter","value":{"rootMargin":"200px"}}" await-children>Every attribute does real work (verified directly against node_modules/astro/dist/runtime/server/astro-island.js, the client-side custom element definition):
uid— a stable id for this island instance.component-url/renderer-url— the two JS chunksastro-island.jsimport()s: the component itself, and the framework’s hydration renderer (hydrator). Different frameworks on the same page get differentrenderer-urlchunks (proven in the framework-integrations lesson).component-export— which export ofcomponent-urlto use (defaults to"default").props— the serialized props, tuple-tagged soDate/Map/Set/RegExp/etc. survive the trip ([0, value]here means “plain value”).ssr— present until hydration runs;astro-island.jsremoves it inhydrate().client— literally the directive name ("visible","idle","load","media","only").astro-island.js’sstart()reads this exact attribute and callsAstro[directive](...).opts— the directive’s own options, passed straight through — this is where{ rootMargin: '200px' }from the JSX-style call above ends up, proving the option actually reaches the browser.await-children— tellsconnectedCallbackto wait for Astro’s streaming markers before starting, so a slotted child that hasn’t streamed in yet doesn’t hydrate against missing DOM.
sequenceDiagram participant HTML as astro-island element participant Loader as Astro[client] (e.g. visible.js) participant JS as component-url + renderer-url HTML->>HTML: connectedCallback (waits for await-children) HTML->>Loader: start() reads the "client" attribute, calls Astro[directive] Loader->>Loader: wait for the real trigger (IntersectionObserver, requestIdleCallback, matchMedia...) Loader->>JS: import() component-url and renderer-url JS-->>HTML: hydrate(Component, props, slots) HTML->>HTML: remove "ssr" attribute, dispatch astro:hydrate
Each directive’s own hydration trigger is a tiny script Astro ships as self.Astro.<directive> — quoting the real, installed files (node_modules/astro/dist/runtime/client/*.js, [email protected]):
const idleDirective = (load, options) => { const cb = async () => { const hydrate = await load(); await hydrate(); }; const rawOptions = typeof options.value === "object" ? options.value : void 0; const idleOptions = { timeout: rawOptions?.timeout }; if ("requestIdleCallback" in window) { window.requestIdleCallback(cb, idleOptions); } else { setTimeout(cb, idleOptions.timeout || 200); }};const visibleDirective = (load, options, el) => { const cb = async () => { const hydrate = await load(); await hydrate(); }; const rawOptions = typeof options.value === "object" ? options.value : void 0; const ioOptions = { rootMargin: rawOptions?.rootMargin }; const io = new IntersectionObserver((entries) => { for (const entry of entries) { if (!entry.isIntersecting) continue; io.disconnect(); cb(); break; } }, ioOptions); for (const child of el.children) { io.observe(child); }};const mediaDirective = (load, options) => { const cb = async () => { const hydrate = await load(); await hydrate(); }; if (options.value) { const mql = matchMedia(options.value); if (mql.matches) { cb(); } else { mql.addEventListener("change", cb, { once: true }); } }};client:load and client:only (load.js/only.js) are the simplest of all — await load(); await hydrate(); with no gate at all — confirming “immediately” really does mean “no observer, no timer, no check.”
Directive options: rootMargin and timeout
Section titled “Directive options: rootMargin and timeout”Both client:visible and client:idle take an object argument, not just a bare presence flag — verified in the installed .d.ts (astro/dist/types/public/elements.d.ts):
'client:load'?: boolean;'client:idle'?: IdleRequestOptions | boolean;'client:media'?: string;'client:visible'?: ClientVisibleOptions | boolean;'client:only'?: boolean | string;
export type ClientVisibleOptions = Pick<IntersectionObserverInit, 'rootMargin'>;IdleRequestOptions is the standard DOM lib type for requestIdleCallback’s second argument (so { timeout }, matching idle.js’s idleOptions.timeout above). ClientVisibleOptions is narrowed to exactly rootMargin — the one field visible.js actually reads into its IntersectionObserverInit. A real, type-checked page using both:
---import DirectiveCounter from '../components/DirectiveCounter.tsx';---<DirectiveCounter start={0} client:visible={{ rootMargin: '200px' }} /><DirectiveCounter start={0} client:idle={{ timeout: 500 }} />import { useState } from 'preact/hooks';
export default function DirectiveCounter({ start }: { start: number }) { const [count, setCount] = useState(start); return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;}npm run verify type-checks both fences above clean — rootMargin/timeout are real, typed options in 7.3.5, not an invented API.
client:only must name a framework — proven with the real error
Section titled “client:only must name a framework — proven with the real error”client:only skips server rendering, so Astro has nothing to render-and-inspect to guess the framework from. With exactly one UI integration installed, Astro quietly infers it for you — client:only (no name) builds fine. The moment a second JSX-capable integration is added (say, preact() and react() in the same project), that inference becomes ambiguous, and building a bare client:only throws for real:
[NoClientOnlyHint] Unable to render `Counter`. When using the `client:only` hydrationstrategy, Astro needs a hint to use the correct renderer. Hint: Did you mean to pass `client:only="react|preact|solid-js|vue (jsx)"`? Error reference: https://docs.astro.build/en/reference/errors/no-client-only-hint/(Proven against a real astro build with @astrojs/preact and @astrojs/react both installed — the same project also warns at the point of adding the second integration: [@astrojs/preact] More than one JSX renderer is enabled. This will lead to unexpected behavior unless you set the include or exclude option.) Naming the framework — client:only="preact" — resolves it immediately, because it tells Astro which renderer-url to load without ever needing a server render.
What actually happens to a non-serializable prop
Section titled “What actually happens to a non-serializable prop”Island props travel from the .astro file to the browser as a JSON-ish string in the props attribute above, so they must be serializable. The constraint is real, but the shape of the failure depends on whether the component’s prop types are honest.
If DirectiveCounter’s Props type is { start: number } and a lesson author passes a handler anyway, TypeScript catches it before anything ships — a real astro check error, not a vague lint:
---// @expect-error TS2322 — onPoint isn't declared on DirectiveCounter's Props, so the extra prop is rejected at the type levelimport DirectiveCounter from '../components/DirectiveCounter.tsx';---<DirectiveCounter start={0} onPoint={() => console.log('hi')} client:visible />src/pages/fnprop-demo.astro:5:20 - error ts(2322): Type '{ start: number; onPoint: () => void; "client:visible": true; }'is not assignable to type 'IntrinsicAttributes & { start: number; }'. Property 'onPoint' does not exist on type 'IntrinsicAttributes & { start: number; }'.But if the prop type is loose (plain .jsx, or an untyped/any prop), nothing stops the build — and the runtime behavior is not an error at all, it’s silent data loss. Astro’s own serializer (astro/dist/runtime/server/serialize.js) falls through to JSON.stringify for anything it doesn’t specifically recognize (Date, RegExp, Map, Set, arrays, typed arrays, Infinity…) — and JSON.stringify of a function inside an array slot produces null, not an exception. The real built props attribute for that same call, with the type check bypassed:
props="{"start":[0,0],"onPoint":[0,null]}"The function simply isn’t there on the client. If Counter ever called props.onPoint(), it would throw onPoint is not a function at hydration time — a confusing runtime error one page-load after the actual mistake.
Hydration mismatches: Preact doesn’t warn the way React does
Section titled “Hydration mismatches: Preact doesn’t warn the way React does”A hydration mismatch is when the server-rendered HTML and the client’s first render disagree — for example a component that branches on typeof window. Built and served for real (<p>{typeof window === 'undefined' ? 'server' : 'client'}</p>, client:load):
$ curl -s http://127.0.0.1:4601/mismatch-demo/ | grep -o '<p>[^<]*</p>'<p>server</p>After the island hydrates in a real browser, the DOM silently becomes:
document.querySelector('astro-island p').outerHTML// "<p>client</p>"with zero console messages — checked directly with a real browser session (Playwright’s console listener recorded 0 errors, 0 warnings across the navigation). This is a real, useful difference from React: Preact (this course’s own framework) does not ship a hydration-mismatch warning in production, and even preact/debug — its dev-mode diagnostics package — doesn’t check text-content mismatches (verified: its warnings cover invalid props, missing keys, and invalid DOM nodes, not this). Don’t expect the console to tell you when server and client output disagree; a genuinely different render on first paint is the only outward sign.
Choosing the right one
Section titled “Choosing the right one”A simple decision path covers most cases:
- Must it work the moment the page loads, above the fold? →
client:load. - Is it below the fold, or not needed instantly? →
client:visible(the safest lean default). - Important but can wait for a quiet moment? →
client:idle. - Only relevant at a certain screen size? →
client:media. - Can it not render on the server at all? →
client:only="react".
The instinct to reach for client:load on everything is exactly what islands exist to prevent. Ship the least JavaScript that delivers the interactivity, and let most of your islands be client:visible.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet uses a client directive, check for these before trusting it:
- Is every interactive component
client:load, regardless of where it sits on the page? That’s the default an LLM reaches for; it ships the most JavaScript for the least benefit unless the component is genuinely above-the-fold and must work instantly. - If
client:onlyappears, does it name a framework (client:only="preact")? Bareclient:onlyonly works by luck — it fails the moment a second UI integration is installed, with the realNoClientOnlyHinterror above. - Is an
is:inline<script>being treated as if it were an island?is:inlineonly tells Astro to leave a plain<script>tag alone (skip bundling/processing) — it has nothing to do with framework components, hydration, or client directives, and it never produces an<astro-island>. - Are props passed to the island genuinely serializable? A function or class instance either fails at the type level (proven above) or silently becomes
nullon the wire — neither is “it just works.” - Does
client:visible/client:idlepass an options object ({{ rootMargin: '...' }},{{ timeout: ... }}) where the code means to configure it, rather than a bare string (that’sclient:media’s shape, not these two)?
🐛 Spot the bug
---// src/pages/index.astro — a project with both @astrojs/preact and @astrojs/react installedimport Counter from '../components/Counter.jsx';---<Counter start={0} client:only />Show the answer
This builds fine with a single integration installed, but the project here has two JSX-capable integrations (preact() and react()). client:only skips server rendering entirely, so Astro has no rendered output to infer the framework from — and with more than one candidate renderer available, guessing is no longer safe. The real build error, reproduced against this exact setup: [NoClientOnlyHint] Unable to render Counter. When using the client:only hydration strategy, Astro needs a hint to use the correct renderer. The fix is one word: client:only="react" (or "preact", whichever Counter.jsx is actually meant to hydrate with, matching each integration’s own include/exclude — see the framework-integrations lesson).
Mental Model
Section titled “Mental Model”A client directive is not a hint for some in-memory Astro runtime — it’s a compile step. client:visible on a JSX component becomes a real <astro-island client="visible" ...> tag in the HTML you can curl, wired to a real IntersectionObserver in a real, versioned file under node_modules/astro/dist/runtime/client/. If you can name the directive, you can go read the sixty-line file that implements it — there’s no hidden layer underneath.
Trade-offs
Section titled “Trade-offs”client:load |
client:idle |
client:visible |
client:media |
client:only |
|
|---|---|---|---|---|---|
| Hydrates | Immediately | On browser idle (requestIdleCallback) |
On scroll into view (IntersectionObserver) |
On a matching media query | Immediately, client-only |
| Server render? | Yes | Yes | Yes | Yes | No — skipped entirely |
| Needs a framework name? | No | No | No | No | Yes, once >1 integration is installed |
| Best for | Above-the-fold, must-work-now controls | Important but not urgent | Below-the-fold widgets (the safe default) | Breakpoint-only interactivity | Browser-only APIs the server would crash on |
Common Misconceptions
Section titled “Common Misconceptions”- “
client:loadis the safe default.” It’s the most expensive one — proven above,load.jshas no gate at all.client:visibleis the safer default; reserveclient:loadfor what genuinely must work the instant the page is usable. - “
client:onlywithout a framework name always works.” Proven above: it depends entirely on how many UI integrations are installed. One integration, it’s inferred; two or more, it’s a real build error. - “A non-serializable prop throws at build time.” Only if the component’s own
Propstype is honest enough for TypeScript to catch it (proven: a realts(2322)). With loose/anytyping, it silently becomesnullon the wire instead — no error at all. - “A hydration mismatch logs a console warning, like in React.” Proven above with a real browser session: Preact does not warn about a text-content mismatch, in production or under
preact/debug. The DOM just silently ends up showing the client’s version.