# 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](/playbooks/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

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 `.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](https://docs.astro.build/en/concepts/islands/), [framework components](https://docs.astro.build/en/guides/framework-components/).

## 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](/playbooks/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](https://docs.astro.build/en/guides/server-islands/), [`@astrojs/preact`](https://docs.astro.build/en/guides/integrations-guide/preact/).

## Baseline

- An Astro 7 site, built as the [Astro](/playbooks/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.

## Parts

| 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 example `FilterPanel`.
- `STORE`: the name of the shared state, for example `cart`.

## Build order

### 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 `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`](https://docs.astro.build/en/guides/integrations-guide/react/), [`@astrojs/react` changelog](https://github.com/withastro/astro/blob/main/packages/integrations/react/CHANGELOG.md).

### 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 `strings` prop. Never `. 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 export default getViteConfig({ test: {} });
```

#### 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

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 `"`.

```ts
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

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

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](https://docs.astro.build/en/guides/testing/), [Container API](https://docs.astro.build/en/reference/container-reference/), [Testing Library `cleanup`](https://testing-library.com/docs/react-testing-library/api#cleanup), [hydration recovery pull request](https://github.com/withastro/astro/pull/16412).

## Change workflow

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.

## 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 dev` and the built site. The [Astro](/playbooks/astro/) playbook applies the same rule to headers and CSP.

## 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={}` | 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

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

```sh
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`:

```sh
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.

### 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: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.

## Sources

- [Astro: islands architecture](https://docs.astro.build/en/concepts/islands/)
- [Astro: framework components](https://docs.astro.build/en/guides/framework-components/)
- [Astro: client directives](https://docs.astro.build/en/reference/directives-reference/#client-directives)
- [Astro: `@astrojs/react`](https://docs.astro.build/en/guides/integrations-guide/react/)
- [Astro: `@astrojs/preact`](https://docs.astro.build/en/guides/integrations-guide/preact/)
- [Astro: share state between islands](https://docs.astro.build/en/recipes/sharing-state-islands/)
- [Astro: server islands](https://docs.astro.build/en/guides/server-islands/)
- [Astro: styling](https://docs.astro.build/en/guides/styling/)
- [Astro: actions](https://docs.astro.build/en/guides/actions/)
- [Astro: environment variables](https://docs.astro.build/en/guides/environment-variables/)
- [Astro: configuration reference](https://docs.astro.build/en/reference/configuration-reference/)
- [Astro: testing](https://docs.astro.build/en/guides/testing/)
- [Astro: Container API](https://docs.astro.build/en/reference/container-reference/)
- [Astro: upgrade to Astro 7](https://docs.astro.build/en/guides/upgrade-to/v7/)
- [`@astrojs/react` changelog](https://github.com/withastro/astro/blob/main/packages/integrations/react/CHANGELOG.md)
- [`@astrojs/react` server renderer source](https://github.com/withastro/astro/blob/main/packages/integrations/react/src/server.ts)
- [Astro prop serializer source](https://github.com/withastro/astro/blob/main/packages/astro/src/runtime/server/serialize.ts)
- [Astro `client:visible` source](https://github.com/withastro/astro/blob/main/packages/astro/src/runtime/client/visible.ts)
- [Astro pull request 16412: hydration error event](https://github.com/withastro/astro/pull/16412)
- [React: `hydrateRoot`](https://react.dev/reference/react-dom/client/hydrateRoot)
- [React: `useSyncExternalStore`](https://react.dev/reference/react/useSyncExternalStore)
- [React: `useId`](https://react.dev/reference/react/useId)
- [Nano Stores](https://github.com/nanostores/nanostores)
- [Testing Library: `cleanup`](https://testing-library.com/docs/react-testing-library/api#cleanup)