Framework Integrations
Bring your own framework
Section titled “Bring your own framework”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:
npx astro add reactThat updates astro.config.mjs for you:
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 componentimport { useState } from 'react';
export default function Counter({ start }) { const [count, setCount] = useState(start); return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;}---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.
Mixing frameworks on one page
Section titled “Mixing frameworks on one page”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"]
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 unexpectedbehavior unless you set the include or exclude option. Seehttps://docs.astro.build/en/guides/integrations-guide/preact/#combining-multiple-jsx-frameworksThe 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.
Props must be serializable
Section titled “Props must be serializable”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,
Dateobjects 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.
React 19 features inside an island
Section titled “React 19 features inside an island”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 propexport default function ReactField({ ref }) { return <input ref={ref} data-testid="react-field" defaultValue="" />;}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:
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.
Reading AI-generated code
Section titled “Reading AI-generated code”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
.jsxfence claim to be “the React version” of a component when the project’spreact()integration has noexcludefor it? Without disjointinclude/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
.astrointo 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 withuseActionState’s action running as an ordinary client function, not a server round-trip. - Is
npx astro add <framework>skipped in favor of hand-editingastro.config.mjsandpackage.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/excludeimport { 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 exampleimport { 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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”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) |
Common Misconceptions
Section titled “Common Misconceptions”- “Astro picks the right renderer for a
.jsxfile based on what it imports.” Proven above: with two overlapping integrations and noinclude/exclude, the choice isn’t based on the file’s ownimport { ... } 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 anduseActionStateboth 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.