Skip to content

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.

A React island has three parts. Each part has one job and one invariant.

PartRunsJobInvariant
Island shellAn .astro component, on the server onlyLoads the data, resolves the strings, chooses the directive, and renders the islandThe only place that sets client:* for the island
Island rootA React component, on the server and then in the browserOwns the state, the handlers, and the effectsReceives only serializable props
StoreA module in src/stores/, in the browserHolds the state that two or more islands shareStarts 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 .astro file renders can take a client:* 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.

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.

  • An Astro 7 site, built as the Astro playbook describes.
  • @astrojs/react, react, react-dom, @types/react, and @types/react-dom, installed with npx astro add react.
  • nanostores and @nanostores/react, when two islands share state.
  • Vitest, @testing-library/react, and jsdom as dev dependencies.
  • Playwright, for the end-to-end checks.
PartPurpose
react() in astro.config.mjsAdds the React renderer for the server and for the browser
src/islands/NAME.tsxThe island root: the component that an .astro file hydrates
src/components/NAME.astroThe island shell: loads the data, resolves the strings, and sets the directive
src/islands/NAME.test.tsxThe unit test for the island root
vitest.config.tsOne 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 example FilterPanel.
  • STORE: the name of the shared state, for example cart.

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 babel option. @astrojs/react 7 throws an error for it. Put custom Babel plugins in @rolldown/plugin-babel, under vite.plugins.
  • Leave compiler off by default. It turns on the React Compiler through Oxc, and it needs oxc-transform-react. The documentation marks it experimental. Never also turn on babel-plugin-react-compiler for the same components.
  • Leave experimentalReactChildren off. Step 5 explains the exception.
  • Set experimentalDisableStreaming: true only 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 include on each integration, for example react({ include: ['**/islands/react/**'] }). Without include, the build warns “More than one JSX renderer is enabled”, and the wrong renderer can claim a component.

Source: @astrojs/react, @astrojs/react changelog.

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 strings prop. 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.

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:

ValueResult
A functionThe 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 instanceThe browser receives a plain object with no methods.
A RegExp with flagsThe browser receives the pattern without the flags.
An object with a cycleThe build fails.
classThe 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, not class.
  • 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.

Choose the directive in the shell from this table. When two rows match, use the earlier row.

DirectiveUse it whenTrap
client:loadThe island is in the first viewport, opens state from location.hash, or must respond at once, such as a menuEach client:load island competes with the main content for bandwidth
client:idleThe island is needed soon but not first, or its server HTML can be emptyOn a busy main thread, the wait has no limit. Add {{timeout: MS}}
client:visibleThe island is below the fold, and its server HTML always has at least one elementAn 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 menuOutside 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 importThe page has no HTML for it until the JavaScript runs

Follow these rules:

  • client:visible observes the child elements of <astro-island>. An island that renders null or only text on the server has no element to observe. It stays static, and no error appears. Use client:idle for it.
  • An island that opens a dialog, a tab, or a panel from location.hash on arrival must use client:load. The hash target often exists only after hydration, so the browser can’t scroll to it, and a client:visible island below the fold never starts.
  • To start a client:visible island 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 in slot="fallback" to prevent layout shift.

Replace the following:

  • MS: the maximum wait in milliseconds, for example 500.
  • QUERY: a media query, for example (max-width: 50em).

Source: client directives, client:visible source.

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 experimentalReactChildren only when the children must stay markup. It converts children to React elements, at a runtime cost.
  • <astro-island> and <astro-slot> have display: contents, so flex and grid layout pass through them. Child selectors don’t pass through them: .grid > .card doesn’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.

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 CustomEvent on window is 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.

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, and navigator.
  • location.search and location.hash.
  • Date.now(), new Date(), and toLocaleString() without a fixed time zone.
  • Math.random() and crypto.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 a getServerSnapshot function 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 suppressHydrationWarning only on one element whose text differs by design, such as a relative time. It applies one level deep.
  • Use typeof window checks 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.

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 to vite.ssr.noExternal when the server build can’t import it.

Source: styling in Astro, @astrojs/react, React server renderer.

Choose the transport by the output mode of the page:

  • On a static page, call a JSON API with fetch from the island. Read the API origin from a public astro:env field, envField.string({ context: 'client', access: 'public' }).
  • On a page that renders on demand, call an Astro Action: const { data, error } = await actions.ACTION(input). Check isInputError(error) for validation errors.
  • For a form that must work before hydration, define the action with accept: 'form'. In the island, call useActionState(withState(actions.ACTION), INITIAL), with withState from @astrojs/react/actions. Read the state on the server with getActionState. 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.

Use one Vitest config, built on getViteConfig. It loads the Astro Vite plugins, so astro:* imports resolve, and the React transform matches the build.

vitest.config.ts
/// <reference types="vitest/config" />
import { getViteConfig } from 'astro/config';
export default getViteConfig({ test: {} });

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.

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 &quot;, 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.

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.

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.

Use this sequence for each new island:

  1. State the user outcome. Confirm that HTML or a <script> can’t meet it.
  2. Write the props contract of the island root. See step 3.
  3. Write the island root in src/islands/, with its unit test.
  4. Write the shell, and choose the directive. See steps 2 and 4.
  5. Render the shell on the page.
  6. Build the site, and run the checks in the verification section.
  • 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 dev and the built site. The Astro playbook applies the same rule to headers and CSP.
Anti-patternFailure modeAccepted pattern
A handler passed as a propThe server renders with the function, the browser receives null, and the handler never runsDefine 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 islandThe class disappears, with no warningPass className
A class instance passed as a propThe island receives a plain object, and method calls throwPass plain data, and create the instance inside the island
A kebab-case slot name on a React islandThe slot HTML ships two timesUse a camelCase slot name
client:visible on an island that can render nothingThe island never hydrates, with no errorUse client:idle, or always render an element
client:visible on an island that opens state from location.hashThe deep link opens nothingUse client:load
client:load on every islandAll islands download and hydrate during the first load, and the main content loads laterUse client:idle or client:visible for islands that aren’t needed at once
client:only without a framework nameThe build fails with “Missing hint on client:only directive”Write client:only="react"
client:only to silence a hydration errorThe content has no server HTML, and the layout shiftsMove the browser-only read into an effect
One React context provider around several islandsEach island gets the default value, because each island is a separate rootShare state with Nano Stores
Browser storage read in a store initial value or a useState initializerThe first browser render differs from the server render, and React renders the island againRead the value in an effect after hydration
new Date() or toLocaleString() during renderThe static HTML shows the build time or the time zone of the build machine, and hydration failsFormat in the shell with a fixed time zone, or in an effect
A hardcoded element ID in an islandTwo instances on one page share an ID, and labels point to the wrong inputUse useId
A translation catalog imported into an islandEvery language ships in the bundleResolve the strings in the shell, and pass one strings prop
A whole collection passed as a propEvery page carries the serialized data in its HTMLPass IDs, or the part of the data that the island renders
Astro scoped styles for React markupThe styles don’t applyUse a CSS Module in the island
.grid > .card around an islandThe selector doesn’t match through <astro-island>Use a descendant selector
<link>, <title>, or <meta> rendered by a React componentThe 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 startupUse @rolldown/plugin-babel in vite.plugins
Two JSX frameworks without includeThe wrong renderer can claim a componentPut each framework in a folder, and set include
A separate Vitest config without the Astro pluginsastro:* imports don’t resolve, and the team writes stubs that driftBuild the config with getViteConfig
Testing Library without cleanup when Vitest globals are offThe DOM of one test leaks into the next testCall cleanup in afterEach
Hydration checks in astro dev onlyA production build shows only a short error, and serialization problems appear only in the buildTest the built site with astro preview

The pass signal is a build and a test run with exit code 0, and two searches of dist/ that print nothing.

Terminal window
npm run build && npx vitest run; echo "exit: $?"
grep -rlE '<astro-island[^>]*client="visible"[^>]*></astro-island>' dist
grep -rl '<template data-astro-template' dist

The expected results:

  • The first command prints exit: 0.
  • The first grep command prints nothing. A match is a client:visible island with no server HTML, which never hydrates.
  • The second grep command 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:

Terminal window
grep -rhoE '&quot;[A-Za-z]+&quot;:\[0,null\]' dist | sort | uniq -c

Each 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]').length prints 0. A client:media island outside its range is the only exception.
  • The console shows no hydration error and no [astro-island] Error hydrating line.
  • The network panel shows one React runtime file for the page.

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:visible island always renders at least one element on the server.
  • Named slots use camelCase names.
  • No render path and no useState initializer 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.