Skip to content

Framework Integrations

Astro doesn’t have its own client-side component runtime — instead it lets you use the UI framework you already know for the interactive bits. React, Vue, Svelte, Solid, and Preact all work as islands, via official integrations.

You add an integration with the CLI, which installs the package and wires up the config:

Terminal window
npx astro add react

That updates astro.config.mjs for you:

astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
export default defineConfig({
integrations: [react()],
});

Now a .jsx component can be imported into an .astro file and hydrated with a client directive.

Importing a framework component as an island

Section titled “Importing a framework component as an island”

The island is written in the framework’s normal style; the .astro file imports it and decides when it hydrates.

// src/components/Counter.jsx — a plain React component
import { useState } from 'react';
export default function Counter({ start }) {
const [count, setCount] = useState(start);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
src/pages/index.astro
---
import Counter from '../components/Counter.jsx'; // 1. import the framework component
---
<h1>Welcome</h1>
<Counter start={0} client:visible /> <!-- 2. hydrate it as an island -->

Without the client:visible, that Counter would render as static HTML (the button, but no working onClick). The directive is what ships its JavaScript.

Because each island is independent, you can use different frameworks on the same page — a React widget next to a Svelte one — as long as both integrations are installed. This is genuinely useful for incremental migration or for dropping in a component that only exists in one ecosystem.

flowchart TB
  page["Astro page (static HTML)"]
  page --> r["React island — client:load"]
  page --> s["Svelte island — client:visible"]
  page --> v["Vue island — client:idle"]
One page, islands from different frameworks

Two JSX frameworks, same file extension: include/exclude

Section titled “Two JSX frameworks, same file extension: include/exclude”

Preact and React both render .jsx/.tsx, which is ambiguous the moment both integrations are installed at once — this course’s own site uses Preact, and a real project sometimes needs one React-only component alongside it. Installing both with no configuration produces a real warning at every build:

[@astrojs/preact] More than one JSX renderer is enabled. This will lead to unexpected
behavior unless you set the include or exclude option. See
https://docs.astro.build/en/guides/integrations-guide/preact/#combining-multiple-jsx-frameworks

The fix is a real, working config that gives each integration a disjoint set of files — proven by building this exact astro.config.mjs against a project with both a .tsx (Preact) and a .jsx (React) island:

import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import preact from '@astrojs/preact';
import react from '@astrojs/react';
export default defineConfig({
adapter: node({ mode: 'standalone' }),
integrations: [
preact({ include: ['**/*.tsx'] }),
react({ include: ['**/*.jsx'] }),
],
});

With that split, a .jsx file is unambiguously React’s and a .tsx file is unambiguously Preact’s — the warning disappears, and the built HTML proves each one hydrated with its own renderer (different renderer-url chunks, and a React-only prefix="r1" attribute that Preact’s own islands don’t get):

<!-- .tsx (Preact) -->
<astro-island uid="Z22rHo8" data-preact-island-id="1" component-url="/_astro/Counter.CzecfPIK.js" renderer-url="/_astro/client.N277o4ni.js" ... client="visible" ...>
<!-- .jsx (React) -->
<astro-island uid="2lYo4w" prefix="r1" component-url="/_astro/RefDemo.OsmBpWw5.js" renderer-url="/_astro/client.CLhIxG29.js" ... client="load" ...>

This course’s own convention: default to .tsx for a Preact island — it’s what the rest of the site already uses — and only reach for a real .jsx/React island when a lesson is specifically demonstrating React itself, matching the include split above.

When you pass props from an .astro file into an island, they are rendered on the server and serialized into the HTML so the client can rehydrate with the same values. That imposes one hard rule: island props must be serializable — strings, numbers, booleans, arrays, plain objects. What you cannot pass:

  • Functions (including event handlers). There’s no way to serialize a function across the server/client boundary.
  • Class instances, Date objects in some cases, and other non-plain values.
---
import Chart from '../components/Chart.jsx';
const data = await getMetrics(); // fetched on the server
---
<Chart data={data} client:visible /> <!-- fine: data is a plain array/object -->
<!-- <Chart onPoint={(p) => ...} /> — NOT allowed: functions cannot be serialized -->

If you need behavior instead of data, put that logic inside the island, or pass server-rendered children through a slot (covered next lesson). The framing that keeps you out of trouble: an .astro file hands an island data, not callbacks.

This site’s own @astrojs/react installs React 19.3.0. Because a React island hydrates into an ordinary, independent React tree, most React 19 features work exactly as they would in a plain React app — proven with two of the headline ones.

ref as a plain prop (no forwardRef needed as of React 19) works inside an island, including forwarding it into a plain function component one level down:

// ReactField.jsx — React-only: a plain function component receiving `ref` as a normal prop
export default function ReactField({ ref }) {
return <input ref={ref} data-testid="react-field" defaultValue="" />;
}
RefDemo.jsx
import { useRef } from 'react';
import ReactField from './ReactField.jsx';
export default function RefDemo() {
const inputRef = useRef(null);
return (
<div>
<ReactField ref={inputRef} />
<button data-testid="focus-btn" onClick={() => inputRef.current?.focus()}>
Focus the field
</button>
</div>
);
}

Built as a client:load island and driven with a real browser — clicking the button really moves focus into the input rendered two components down, with no forwardRef anywhere:

document.activeElement.getAttribute('data-testid')
// "react-field"

useActionState also works, entirely as ordinary client-side React state — there’s no bridge to Astro Actions or a server here, the action function you pass just runs in the browser:

ActionForm.jsx
import { useActionState } from 'react';
async function increment(prevState, formData) {
await new Promise((r) => setTimeout(r, 10));
return prevState + Number(formData.get('step') || 1);
}
export default function ActionForm() {
const [state, formAction, isPending] = useActionState(increment, 0);
return (
<form action={formAction}>
<input type="hidden" name="step" value="5" />
<button type="submit" data-testid="action-btn">
{isPending ? 'Adding...' : `Total: ${state}`}
</button>
</form>
);
}

Server-rendered as Total: 0; a real click submits the form and the state updates entirely client-side:

document.querySelector('[data-testid=action-btn]').textContent
// before click: "Total: 0"
// after click: "Total: 5"

What doesn’t cross the island boundary, even with React 19: a React Server Component or a 'use server' action has no meaning here — an Astro island is an ordinary client component tree, not React’s RSC runtime, so useActionState’s action function is just a normal client-side async function (proven above: no network request happens, it all runs in the browser). And everything from the other lessons still applies inside a React 19 island exactly as it does anywhere else: props crossing from .astro into the island must still be serializable (proven above), and Context.Provider still doesn’t reach a sibling island (proven in the sharing-state lesson) — React 19 changes what you can write inside one island’s own tree, not what crosses between islands.

When a generated snippet mixes frameworks or reaches for a newer framework feature inside an island, check for these before trusting it:

  • Are two JSX-capable integrations (Preact + React, Preact + Solid…) installed with no include/exclude? Proven above: that’s a real warning at every build (More than one JSX renderer is enabled), not a hypothetical.
  • Does a .jsx fence claim to be “the React version” of a component when the project’s preact() integration has no exclude for it? Without disjoint include/exclude, which renderer actually claims the file depends on integration registration order, not on which framework’s syntax the code happens to use.
  • Is a class or function prop passed from .astro into any framework’s island, regardless of which framework? The serializable-props rule (proven above) applies identically to React, Preact, Vue, Svelte, and Solid islands — it’s a property of the island boundary, not of one framework.
  • Does a snippet assume a React Server Component pattern ('use server', an RSC-only API) works inside an Astro island? An island is a plain client component tree for whichever framework renders it — there’s no RSC runtime underneath, proven above with useActionState’s action running as an ordinary client function, not a server round-trip.
  • Is npx astro add <framework> skipped in favor of hand-editing astro.config.mjs and package.json? Both work, but the CLI also installs the package and peer dependencies correctly — a hand-edited config with a missing dependency fails at a confusing spot (a resolution error deep in the build), not at the config itself.

🐛 Spot the bug

// astro.config.mjs — both integrations installed, no include/exclude
import { defineConfig } from 'astro/config';
import preact from '@astrojs/preact';
import react from '@astrojs/react';
export default defineConfig({
integrations: [preact(), react()],
});
// src/components/RefDemo.jsx — meant to be the React-only example
import { useRef } from 'react';
export default function RefDemo() { /* ... */ }
Show the answer

Both integrations claim .jsx here — neither has an include/exclude narrowing it to a specific pattern. Proven above: this configuration warns on every build ([@astrojs/preact] More than one JSX renderer is enabled), and worse, which renderer actually ends up handling RefDemo.jsx is not guaranteed to be React just because the code imports from 'react' — Astro picks by registration/matching order, not by inspecting the file’s own imports. The fix is the disjoint split proven above: preact({ include: ['**/*.tsx'] }) and react({ include: ['**/*.jsx'] }), so a .jsx file is unambiguously React’s and a .tsx file is unambiguously Preact’s.

An integration doesn’t “own” a framework globally — it owns a set of file patterns (via include/exclude, defaulting to every file that framework’s Vite plugin can handle). Two integrations with overlapping patterns are two claims on the same files, and Astro has to pick one — which is exactly the ambiguity proven above. Naming the pattern explicitly is what makes “which renderer handles this file” a fact about your config, not a guess Astro makes for you.

Single framework, no include/exclude Multiple frameworks, disjoint include/exclude
Config complexity Simplest — just integrations: [preact()] One include pattern per integration
File extension ambiguity None — only one JSX-capable integration exists None, as long as patterns don’t overlap
What breaks if you skip it Nothing (nothing to conflict with) A real build warning, and an unpredictable choice of renderer per file
Best for Every lesson in this course, and most real projects Deliberately mixing ecosystems (migration, a component that only exists in one framework)
  • “Astro picks the right renderer for a .jsx file based on what it imports.” Proven above: with two overlapping integrations and no include/exclude, the choice isn’t based on the file’s own import { ... } from 'react' — it’s based on integration registration/matching, and it warns rather than silently doing the right thing.
  • “React 19 features need special Astro support to work in an island.” Proven above: ref-as-prop and useActionState both just work, because a React island is an ordinary React 19 tree underneath — Astro doesn’t need to do anything special for a language/library feature that lives entirely inside one island’s own render.
  • “useActionState’s action function runs on the server, like a Next.js Server Action.” Proven above: it’s an ordinary client-side async function — there’s no RSC bridge, no network request, nothing server-side about it inside a plain client island.
  • “The serializable-props rule is a React-specific limitation.” It applies at the island boundary itself, for every framework Astro supports — proven identically for Preact in the client-directives lesson and for React here.
How do you add support for React components in an Astro project?
Can you use a React island and a Svelte island on the same page?
Why can't you pass a function (like an event handler) as a prop to an island from an .astro file?
A framework component imported into an .astro file with NO client directive will:
This real build warning appears when a project has:
[@astrojs/preact] More than one JSX renderer is enabled. This will lead to unexpected
behavior unless you set the include or exclude option.
Used inside an Astro island hydrated with client:load, does this work?
// src/components/ReactField.jsx — React 19, no forwardRef
export default function ReactField({ ref }) {
  return <input ref={ref} />;
}