Skip to content
Astro as integrations 4 min read

React Integration

Astro renders to zero JavaScript by default, but real applications often need interactive widgets — a search box, a date picker, a stateful dashboard. The @astrojs/react integration lets you author those interactive pieces as ordinary React components and ship them as islands: isolated regions of hydrated JavaScript embedded in otherwise static HTML. You keep React’s component model and ecosystem while Astro controls exactly when and where the bundle loads.

Installing the integration

The fastest path is the astro add command, which installs the integration plus its react and react-dom peer dependencies and wires up astro.config.mjs for you.

npx astro add react

If you prefer to do it by hand, install the packages and register the integration yourself:

npm install @astrojs/react react react-dom
npm install -D @types/react @types/react-dom
// astro.config.mjs
import { defineConfig } from "astro/config";
import react from "@astrojs/react";

export default defineConfig({
  integrations: [react()],
});

Astro can run multiple UI frameworks side by side. If you also use Vue or Svelte, list each integration in the same integrations array — Astro scopes each renderer to the files it owns.

Writing a React component

React components live anywhere in your project, typically under src/components. Use .jsx or .tsx so the JSX transform applies. Nothing here is Astro-specific — it is plain React with hooks.

// src/components/Counter.tsx
import { useState } from "react";

interface CounterProps {
  start?: number;
}

export default function Counter({ start = 0 }: CounterProps) {
  const [count, setCount] = useState(start);

  return (
    <div className="counter">
      <button onClick={() => setCount((c) => c - 1)}>-</button>
      <output>{count}</output>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
    </div>
  );
}

Hydrating with client directives

Import the component into a .astro file and place it in markup like any element. Without a client:* directive Astro renders the component to static HTML at build time and ships no JavaScript — the buttons would not work. The directive tells Astro when to hydrate the island in the browser.

---
// src/pages/index.astro
import Counter from "../components/Counter.tsx";
---

<html lang="en">
  <body>
    <h1>Static heading rendered with zero JS</h1>

    <!-- Hydrates immediately on page load -->
    <Counter client:load start={5} />

    <!-- Hydrates only when scrolled into view -->
    <Counter client:visible />
  </body>
</html>

Each directive controls the hydration strategy independently:

DirectiveWhen it hydratesBest for
client:loadImmediately on page loadAbove-the-fold, high-priority UI
client:idleWhen the main thread is idleLow-priority interactivity
client:visibleWhen the element enters the viewportBelow-the-fold widgets
client:media={query}When a CSS media query matchesMobile-only or desktop-only UI
client:only="react"Skips SSR; renders only in the browserComponents that touch window/document

Use client:only="react" when a component cannot render on the server (for example it reads localStorage at module top level). You must pass the framework name so Astro knows which renderer to load.

Passing props and children

Props serialize from the Astro server to the React island, so they must be JSON-serializable — strings, numbers, booleans, arrays, and plain objects. Functions and class instances will not survive the boundary. You can also pass server-rendered markup as children using a named slot.

---
import Tabs from "../components/Tabs.tsx";
---

<Tabs client:visible labels={["Docs", "API"]}>
  <p slot="panel-0">Static documentation content.</p>
  <p slot="panel-1">Static API reference.</p>
</Tabs>

In React, children arrive as standard props. Slotted content is passed through Astro’s slot mechanism and rendered inside the island.

Sharing state between islands

Each island is a separate React tree, so a top-level Context provider does not wrap them all. To share reactive state across islands, use a framework-agnostic store such as Nano Stores, which Astro recommends for exactly this case.

// src/stores/cart.ts
import { atom } from "nanostores";

export const itemCount = atom(0);
// src/components/CartBadge.tsx
import { useStore } from "@nanostores/react";
import { itemCount } from "../stores/cart";

export default function CartBadge() {
  const count = useStore(itemCount);
  return <span className="badge">{count}</span>;
}

Any island reading itemCount re-renders when another island calls itemCount.set(...), even though they are mounted independently.

Best Practices

  • Reach for the lightest hydration directive that works — prefer client:visible or client:idle over client:load to minimize main-thread work.
  • Keep islands small and focused; render static structure in Astro and hydrate only the interactive leaf, not the whole page.
  • Ensure every prop crossing the server-to-client boundary is JSON-serializable; move functions and side effects inside the component.
  • Use client:only="react" for components that depend on browser-only APIs to avoid SSR hydration mismatches.
  • Share cross-island state with Nano Stores rather than React Context, which cannot span separate islands.
  • Pin matching react and react-dom versions and keep @astrojs/react updated so the JSX transform stays in sync.
Last updated June 14, 2026
Was this helpful?