Astro React Islands
Use this playbook to add interactive React components to an Astro site. A React island is a React component that Astro renders to HTML on the server and then hydrates in the browser as a separate React root. The rest of the page stays static HTML. This playbook starts where step 11 of the Astro playbook stops: you chose a UI framework island, and the framework is React.
This guidance targets Astro 7, @astrojs/react 7, and React 19. @astrojs/react 7 replaced Babel with Oxc and removed the babel option, so older setup advice fails. The basis is the Astro 7 documentation, the source code of astro and @astrojs/react, a test build on Astro 7, and GitHub issues that report real failures. Recheck the linked documentation before each major release of Astro or @astrojs/react.
The one rule: only data crosses the island boundary. Astro serializes the props of each island into the HTML, children arrive as static HTML, and React context stops at the edge of the island. Define every handler, provider, and piece of browser state inside the island.
Core model
Section titled “Core model”A React island has three parts. Each part has one job and one invariant.
| Part | Runs | Job | Invariant |
|---|---|---|---|
| Island shell | An .astro component, on the server only | Loads the data, resolves the strings, chooses the directive, and renders the island | The only place that sets client:* for the island |
| Island root | A React component, on the server and then in the browser | Owns the state, the handlers, and the effects | Receives only serializable props |
| Store | A module in src/stores/, in the browser | Holds the state that two or more islands share | Starts with the same value on the server and in the browser |
These invariants apply to every step:
- Each island is a separate React root. State, context, and error boundaries don’t cross from one island to another.
- Astro renders each island two times: one time on the server, and one time in the browser during hydration. The first browser render must produce the same HTML as the server render.
- Only a component that an
.astrofile renders can take aclient:*directive. The React components that the island root imports are part of that island. - The React runtime loads one time per page, and all React islands on the page share it. A test build on Astro 7 measured the runtime at about 69 KB gzip. Each additional island adds only its own code.
Source: islands architecture, framework components.
When to use it
Section titled “When to use it”Use this playbook when all of these conditions apply:
- The site is an Astro site, and most of each page is static. See the Astro playbook.
- A component holds client state that a
<script>can’t manage cleanly, for example a filter panel, a multistep form, or a dialog with a focus trap. - The team knows React, or the component needs a library that exists only for React.
Use another approach when any of these conditions apply:
- The interaction is a disclosure, a toggle, or a small counter. Use HTML or a
<script>, as step 11 of the Astro playbook describes. - The component needs no React-only library, and page weight matters. Use Preact with
compat: true. In a test build, the same component shipped about 13 KB gzip with Preact and about 69 KB gzip with React. - Most of the screen shares client state, as in a dashboard or an editor. Use a client application framework, or make the whole screen one island.
- The data changes on each request and must render on the server. Use a server island: an Astro component with
server:defer.
Source: server islands, @astrojs/preact.
Baseline
Section titled “Baseline”- An Astro 7 site, built as the Astro playbook describes.
@astrojs/react,react,react-dom,@types/react, and@types/react-dom, installed withnpx astro add react.nanostoresand@nanostores/react, when two islands share state.- Vitest,
@testing-library/react, andjsdomas dev dependencies. - Playwright, for the end-to-end checks.
| Part | Purpose |
|---|---|
react() in astro.config.mjs | Adds the React renderer for the server and for the browser |
src/islands/NAME.tsx | The island root: the component that an .astro file hydrates |
src/components/NAME.astro | The island shell: loads the data, resolves the strings, and sets the directive |
src/islands/NAME.test.tsx | The unit test for the island root |
vitest.config.ts | One test config, built on getViteConfig |
Add these parts when their problem appears:
src/stores/STORE.ts: two islands on one page must share state.src/islands/NAME.module.css: the island has its own styles.- A Container API test for the shell: the shell transforms data or chooses between directives.
Replace the following:
NAME: the name of the island in PascalCase, for exampleFilterPanel.STORE: the name of the shared state, for examplecart.
Build order
Section titled “Build order”1. Add the integration
Section titled “1. Add the integration”Run npx astro add react. The command installs the packages, adds react() to the Astro config, and sets jsx and jsxImportSource in tsconfig.json.
Follow these rules for the integration options:
- Never pass a
babeloption.@astrojs/react7 throws an error for it. Put custom Babel plugins in@rolldown/plugin-babel, undervite.plugins. - Leave
compileroff by default. It turns on the React Compiler through Oxc, and it needsoxc-transform-react. The documentation marks it experimental. Never also turn onbabel-plugin-react-compilerfor the same components. - Leave
experimentalReactChildrenoff. Step 5 explains the exception. - Set
experimentalDisableStreaming: trueonly for a CSS-in-JS library that fails with streaming. Step 8 explains. - When the project has a second JSX framework, such as Preact or Solid, put each framework in its own folder and set
includeon each integration, for examplereact({ include: ['**/islands/react/**'] }). Withoutinclude, the build warns “More than one JSX renderer is enabled”, and the wrong renderer can claim a component.
Source: @astrojs/react, @astrojs/react changelog.
2. Write the shell and the island root
Section titled “2. Write the shell and the island root”Put every island root in src/islands/. Render each island from one Astro shell in src/components/. Pages render the shell, never the island root.
The shell owns these decisions:
- The directive. Step 4 explains the choice. One shell gives every page the same directive.
- The data. The shell loads the data and passes only the part that the island renders.
- The strings. The shell resolves the translated strings and passes them as one
stringsprop. Never import a translation catalog into an island, because everything that an island imports ships in its bundle.
The island root owns the state, the handlers, and the effects.
Use React only for islands. Write static markup as Astro components. A React component without a directive ships no JavaScript, but its effects and handlers never run, so it only adds a second component system.
Source: framework components.
3. Define the props contract
Section titled “3. Define the props contract”Type the props of each island root. Pass only these types: string, number, boolean, null, undefined, plain objects, arrays, Date, Map, Set, BigInt, URL, Uint8Array, Uint16Array, Uint32Array, Infinity, and RegExp. Astro serializes each prop into the props attribute of the <astro-island> element.
These values break at the boundary:
| Value | Result |
|---|---|
| A function | The server render receives the function. The browser receives null. The two renders can differ. |
Markup, for example icon={<Icon />} | The build fails with “Objects are not valid as a React child”. |
| A class instance | The browser receives a plain object with no methods. |
A RegExp with flags | The browser receives the pattern without the flags. |
| An object with a cycle | The build fails. |
class | The React renderer removes it on the server and in the browser. |
Follow these rules:
- Define handlers inside the island. When another island must react to an event, share the state through a store. See step 6.
- Pass markup as a named slot. See step 5.
- Pass
className, notclass. - Pass IDs or the part of the data that the island renders. Every prop is in the HTML of every page that renders the island.
- Format a date in the shell when every visitor sees the same text. Format it in an effect when the text depends on the time zone of the visitor. In a static build,
new Date()is the build time.
Source: framework components, prop serializer, React server renderer.
4. Choose the directive
Section titled “4. Choose the directive”Choose the directive in the shell from this table. When two rows match, use the earlier row.
| Directive | Use it when | Trap |
|---|---|---|
client:load | The island is in the first viewport, opens state from location.hash, or must respond at once, such as a menu | Each client:load island competes with the main content for bandwidth |
client:idle | The island is needed soon but not first, or its server HTML can be empty | On a busy main thread, the wait has no limit. Add {{timeout: MS}} |
client:visible | The island is below the fold, and its server HTML always has at least one element | An island with no element in its server HTML never hydrates |
client:media="QUERY" | The island exists only for one viewport range, such as a mobile menu | Outside the range, it never hydrates. Hide it with CSS at the same breakpoint |
client:only="react" | The component can’t render on the server, for example it reads window on import | The page has no HTML for it until the JavaScript runs |
Follow these rules:
client:visibleobserves the child elements of<astro-island>. An island that rendersnullor only text on the server has no element to observe. It stays static, and no error appears. Useclient:idlefor it.- An island that opens a dialog, a tab, or a panel from
location.hashon arrival must useclient:load. The hash target often exists only after hydration, so the browser can’t scroll to it, and aclient:visibleisland below the fold never starts. - To start a
client:visibleisland before it enters the viewport, add a margin:client:visible={{rootMargin: "200px"}}. - Always write the framework name in
client:only. Without it, the build fails with “Missing hint on client:only directive”. Put a placeholder of the same size inslot="fallback"to prevent layout shift.
Replace the following:
MS: the maximum wait in milliseconds, for example500.QUERY: a media query, for example(max-width: 50em).
Source: client directives, client:visible source.
5. Pass Astro content through slots
Section titled “5. Pass Astro content through slots”Pass Astro markup to an island as children or as named slots. Never import an .astro file into a React component.
Children and slots arrive as static HTML inside an <astro-slot> element, not as React elements. React places the HTML, but it can’t read, map, or clone the HTML, and it can’t add props to it.
Follow these rules:
- Write named slots in camelCase, for example
slot="footerNote". The renderer converts a kebab-case name to camelCase for the prop, but Astro then searches the output for the original name. Astro finds no match, so it ships the slot HTML a second time in a<template>element. - When the island must inspect its children, for example tabs that map each child to a tab, pass the data as a prop. Turn on
experimentalReactChildrenonly when the children must stay markup. It converts children to React elements, at a runtime cost. <astro-island>and<astro-slot>havedisplay: contents, so flex and grid layout pass through them. Child selectors don’t pass through them:.grid > .carddoesn’t match a card inside an island. Use a descendant selector, or style the root element of the island.
Source: framework components, React server renderer, component renderer.
6. Share state between islands
Section titled “6. Share state between islands”Put shared state in a Nano Stores atom in src/stores/. Read it in each island with useStore from @nanostores/react. Write it with .set() from any island or from a <script>.
Never wrap islands in one React context provider. Each island is a separate React root, so a provider in one island doesn’t reach another island.
Follow these rules:
- Keep the initial value of the store the same on the server and in the browser. Load values from
localStorage, cookies, or the URL in an effect after hydration. A store that reads browser storage on import makes the first browser render differ from the server render. - For a one-time notification, such as “the session changed”, a
CustomEventonwindowis enough. Add the listener in an effect, and remove it in the cleanup function. - Astro components can’t read a store. The server renders every island with the initial value of the store.
Source: share state between islands, Nano Stores.
7. Match the server render in the browser
Section titled “7. Match the server render in the browser”Make the first browser render produce the same output as the server render. Read browser-only values after hydration.
These values differ between the server and the browser. Never read them during render or in a useState initializer:
window,document,localStorage,matchMedia, andnavigator.location.searchandlocation.hash.Date.now(),new Date(), andtoLocaleString()without a fixed time zone.Math.random()andcrypto.randomUUID().
Use these patterns instead:
- Render the server-safe default. Read the browser value in
useEffect, then set state. - Subscribe to a browser source with
useSyncExternalStore, and pass agetServerSnapshotfunction that returns the default. - Create element IDs with
useId. Astro gives each island its own ID prefix, so the IDs don’t collide when a page renders the island two times. Never hardcode an ID in a reusable island. - Put
suppressHydrationWarningonly on one element whose text differs by design, such as a relative time. It applies one level deep. - Use
typeof windowchecks only in effects and handlers. In render, they produce two different trees.
When the render still differs, React logs a hydration error and renders the island again in the browser. The development server shows the difference. A production build shows only a short message, so test the built site.
Use client:only="react" only when a component can’t render on the server at all, such as a map library that needs window on import.
Source: hydrateRoot, useSyncExternalStore, useId.
8. Style the island
Section titled “8. Style the island”Style each island with a CSS Module, or with global classes that follow the naming of the site. Astro scoped <style> blocks don’t reach the markup that React renders.
Follow these rules:
- Import the stylesheet in the island module:
import styles from './NAME.module.css'. Astro bundles it into the CSS of each page that uses the island. - Read design tokens as CSS custom properties from the global stylesheet. Don’t copy token values into JavaScript.
- Never render
<link>,<title>, or<meta>from a React component. The React renderer removes<link>tags from the server HTML, so the styles load late.<title>and<meta>render inside<astro-island>in the<body>. Head content belongs to the Astro layout. - Use CSS with no runtime. A CSS-in-JS library that injects styles at runtime can fail with streaming. When the team must use one, set
experimentalDisableStreaming: true, and add the library tovite.ssr.noExternalwhen the server build can’t import it.
Source: styling in Astro, @astrojs/react, React server renderer.
9. Call the server
Section titled “9. Call the server”Choose the transport by the output mode of the page:
- On a static page, call a JSON API with
fetchfrom the island. Read the API origin from a publicastro:envfield,envField.string({ context: 'client', access: 'public' }). - On a page that renders on demand, call an Astro Action:
const { data, error } = await actions.ACTION(input). CheckisInputError(error)for validation errors. - For a form that must work before hydration, define the action with
accept: 'form'. In the island, calluseActionState(withState(actions.ACTION), INITIAL), withwithStatefrom@astrojs/react/actions. Read the state on the server withgetActionState. The page must render on demand.
Never put a secret in a value that an island imports. Everything that an island imports ships in the bundle. For this reason, astro:env rejects secret fields with the client context.
Keep security.checkOrigin at its default, true. It checks the Origin header of form and action requests to on-demand pages.
Replace the following:
ACTION: the name of the action.INITIAL: the initial state of the form.
Source: actions, @astrojs/react, environment variables, security.checkOrigin.
10. Test the islands
Section titled “10. Test the islands”Use one Vitest config, built on getViteConfig. It loads the Astro Vite plugins, so astro:* imports resolve, and the React transform matches the build.
/// <reference types="vitest/config" />import { getViteConfig } from 'astro/config';
export default getViteConfig({ test: {} });Test the island root
Section titled “Test the island root”Put // @vitest-environment jsdom on the first line of each .test.tsx file. Render the island with Testing Library, and assert on roles and text.
When Vitest globals are off, call cleanup in afterEach. Testing Library cleans up automatically only when afterEach is a global.
Test the shell
Section titled “Test the shell”Render the shell with the Container API, and assert on the island markup: the client attribute and the props attribute. The props attribute is HTML-escaped, so match ", not ".
import { experimental_AstroContainer as AstroContainer } from 'astro/container';import { loadRenderers } from 'astro:container';import { getContainerRenderer } from '@astrojs/react/container-renderer';
const renderers = await loadRenderers([getContainerRenderer()]);const container = await AstroContainer.create({ renderers });const html = await container.renderToString(Shell);Import getContainerRenderer from @astrojs/react/container-renderer. The import from the package root is deprecated. The Container API is still experimental.
Test in a browser
Section titled “Test in a browser”Run Playwright against astro build and astro preview, not astro dev. Before you interact with an island, wait until its <astro-island> element has no ssr attribute. Astro removes the attribute when the island hydrates.
Report failures in production
Section titled “Report failures in production”Listen for the astro:hydration-error event on window, and send the error and the componentUrl from event.detail to the error tracker. Astro fires it when the code of an island fails to load or to start. Astro retries a failed import one time after one second before it fires the event.
Source: testing, Container API, Testing Library cleanup, hydration recovery pull request.
Change workflow
Section titled “Change workflow”Use this sequence for each new island:
- State the user outcome. Confirm that HTML or a
<script>can’t meet it. - Write the props contract of the island root. See step 3.
- Write the island root in
src/islands/, with its unit test. - Write the shell, and choose the directive. See steps 2 and 4.
- Render the shell on the page.
- Build the site, and run the checks in the verification section.
Design rules
Section titled “Design rules”- Hydrate the smallest component that holds the state. Don’t hydrate the section around it. Static text around an island stays static HTML, and it ships no JavaScript.
- The shell decides, and the island acts. The data, the strings, and the directive live in the Astro shell. The state and the behavior live in the island. A reviewer finds each decision in one place.
- The first island pays for React. The first React island on a page loads the React runtime, and each additional island costs only its own code. A second framework on the same page adds a second runtime.
- Test the build output. Prop serialization, directive timing, and hydration errors differ between
astro devand the built site. The Astro playbook applies the same rule to headers and CSP.
Anti-patterns
Section titled “Anti-patterns”| Anti-pattern | Failure mode | Accepted pattern |
|---|---|---|
| A handler passed as a prop | The server renders with the function, the browser receives null, and the handler never runs | Define the handler inside the island, or share state through a store |
Markup passed as a prop value, such as icon={<Icon />} | The build fails with “Objects are not valid as a React child” | Pass the markup as a named slot |
class passed to a React island | The class disappears, with no warning | Pass className |
| A class instance passed as a prop | The island receives a plain object, and method calls throw | Pass plain data, and create the instance inside the island |
| A kebab-case slot name on a React island | The slot HTML ships two times | Use a camelCase slot name |
client:visible on an island that can render nothing | The island never hydrates, with no error | Use client:idle, or always render an element |
client:visible on an island that opens state from location.hash | The deep link opens nothing | Use client:load |
client:load on every island | All islands download and hydrate during the first load, and the main content loads later | Use client:idle or client:visible for islands that aren’t needed at once |
client:only without a framework name | The build fails with “Missing hint on client:only directive” | Write client:only="react" |
client:only to silence a hydration error | The content has no server HTML, and the layout shifts | Move the browser-only read into an effect |
| One React context provider around several islands | Each island gets the default value, because each island is a separate root | Share state with Nano Stores |
Browser storage read in a store initial value or a useState initializer | The first browser render differs from the server render, and React renders the island again | Read the value in an effect after hydration |
new Date() or toLocaleString() during render | The static HTML shows the build time or the time zone of the build machine, and hydration fails | Format in the shell with a fixed time zone, or in an effect |
| A hardcoded element ID in an island | Two instances on one page share an ID, and labels point to the wrong input | Use useId |
| A translation catalog imported into an island | Every language ships in the bundle | Resolve the strings in the shell, and pass one strings prop |
| A whole collection passed as a prop | Every page carries the serialized data in its HTML | Pass IDs, or the part of the data that the island renders |
| Astro scoped styles for React markup | The styles don’t apply | Use a CSS Module in the island |
.grid > .card around an island | The selector doesn’t match through <astro-island> | Use a descendant selector |
<link>, <title>, or <meta> rendered by a React component | The server HTML loses the <link>, and the <title> lands in the <body> | Import CSS in the island module, and set head content in the Astro layout |
The babel option on react() | @astrojs/react 7 throws an error at startup | Use @rolldown/plugin-babel in vite.plugins |
Two JSX frameworks without include | The wrong renderer can claim a component | Put each framework in a folder, and set include |
| A separate Vitest config without the Astro plugins | astro:* imports don’t resolve, and the team writes stubs that drift | Build the config with getViteConfig |
Testing Library without cleanup when Vitest globals are off | The DOM of one test leaks into the next test | Call cleanup in afterEach |
Hydration checks in astro dev only | A production build shows only a short error, and serialization problems appear only in the build | Test the built site with astro preview |
Verification
Section titled “Verification”The pass signal is a build and a test run with exit code 0, and two searches of dist/ that print nothing.
npm run build && npx vitest run; echo "exit: $?"grep -rlE '<astro-island[^>]*client="visible"[^>]*></astro-island>' distgrep -rl '<template data-astro-template' distThe expected results:
- The first command prints
exit: 0. - The first
grepcommand prints nothing. A match is aclient:visibleisland with no server HTML, which never hydrates. - The second
grepcommand prints nothing. A match is a slot that the island didn’t render on the server, or a kebab-case slot name.
Then find the props that arrive as null:
grep -rhoE '"[A-Za-z]+":\[0,null\]' dist | sort | uniq -cEach line is a prop that the browser receives as null. Confirm that no line is a function prop.
Then run npx astro preview, and open each page that has an island. Scroll to the end of the page. The expected results:
- In the console,
document.querySelectorAll('astro-island[ssr]').lengthprints0. Aclient:mediaisland outside its range is the only exception. - The console shows no hydration error and no
[astro-island] Error hydratingline. - The network panel shows one React runtime file for the page.
Review checklist
Section titled “Review checklist”Use this checklist for each pull request that adds or changes an island.
- The island root is in
src/islands/, and one Astro shell renders it. - Each prop is serializable data. No prop is a function, markup, a class instance, or
class. - The props are IDs or the part of the data that the island renders.
- The directive matches the table in step 4.
- A
client:visibleisland always renders at least one element on the server. - Named slots use camelCase names.
- No render path and no
useStateinitializer reads a browser-only value. - Shared state uses a store, not a context provider across islands.
- Styles use a CSS Module or global classes, not Astro scoped styles.
- No secret reaches an island import.
- The island root has a unit test. The shell has a Container API test when it transforms data.
Sources
Section titled “Sources”- Astro: islands architecture
- Astro: framework components
- Astro: client directives
- Astro:
@astrojs/react - Astro:
@astrojs/preact - Astro: share state between islands
- Astro: server islands
- Astro: styling
- Astro: actions
- Astro: environment variables
- Astro: configuration reference
- Astro: testing
- Astro: Container API
- Astro: upgrade to Astro 7
@astrojs/reactchangelog@astrojs/reactserver renderer source- Astro prop serializer source
- Astro
client:visiblesource - Astro pull request 16412: hydration error event
- React:
hydrateRoot - React:
useSyncExternalStore - React:
useId - Nano Stores
- Testing Library:
cleanup