Astro + React: Using React Components in an Astro Site
Ship React components inside an Astro site without the full SPA overhead. A practical walkthrough of Islands Architecture, hydration directives, and sharing state.
Why Mix Astro and React at All?
Astro ships zero JavaScript by default. That's the pitch. You get static HTML, fast page loads, and none of the hydration tax that comes with a full React SPA. But real projects aren't that simple - you've got an interactive data table here, an animated glassmorphism dropdown there, a countdown timer that has to tick on the client. Rewriting all of that in vanilla JS or Astro's template syntax isn't appealing.
React fills that gap. Astro 4.x lets you drop React components into .astro files with one decorator. Your static content stays static. Your interactive bits get hydrated exactly when they need to be - not before. You're not fighting the framework; you're using each tool for what it's actually good at.
In practice, this combo is what powers a lot of marketing sites and docs portals you'd recognize. Content-heavy pages built in Astro, interactive widgets pulled in from a React component library. If you've ever looked at the Empire UI component catalog and thought 'I want these in my Astro blog,' this post is for you.
Worth noting: Astro's Islands Architecture isn't unique to React. You can mix Vue, Svelte, Solid, and Preact in the same project. But React has the widest ecosystem, so it's the most common pairing - and the one we'll focus on here.
Setting Up the React Integration
If you're starting from scratch, scaffold an Astro project and add React in one shot. Astro 5.x ships with a CLI wizard that handles this:
npm create astro@latest my-site
cd my-site
npx astro add reactThat second command does the work: installs @astrojs/react, react, and react-dom, then patches your astro.config.mjs automatically. If you're adding React to an existing Astro project, same deal - just run npx astro add react in the project root and it patches the config for you.
Your astro.config.mjs should look roughly like this after the integration is added:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import react from '@astrojs/react';
export default defineConfig({
integrations: [react()],
});That's it for setup. Now any .jsx or .tsx file you create is a valid React component you can import into .astro pages. Astro handles the TypeScript and JSX transform so you don't need a separate Babel or Vite config for it.
Islands Architecture: The Hydration Directives
This is where Astro gets interesting. You don't just import a React component and let Astro figure it out - you tell Astro *when* to hydrate it. That decision directly controls how much JavaScript ships to the browser.
There are five client directives. You'll use three of them 90% of the time:
---
import Counter from '../components/Counter.jsx';
import HeavyChart from '../components/HeavyChart.jsx';
import Modal from '../components/Modal.jsx';
---
<!-- Hydrate immediately on page load -->
<Counter client:load />
<!-- Hydrate once the browser is idle -->
<HeavyChart client:idle />
<!-- Hydrate only when the component scrolls into view -->
<Modal client:visible />client:load is the one you reach for when the component needs to be interactive right away - a nav dropdown, a search input, anything above the fold that the user will touch immediately. client:idle is your friend for below-the-fold widgets: charts, comment sections, recommendation carousels. The browser hydrates them during idle time, so they don't compete with critical rendering. client:visible defers hydration until the component actually enters the viewport. A 2026 audit of a mid-sized docs site showed a 38 Kb JS reduction just by switching chart components from client:load to client:visible.
There's also client:only - use it when the component can't render server-side at all (like something that reads window or localStorage on mount). Pass it the renderer name: client:only="react". And client:media for components that should only hydrate above a certain breakpoint, which is handy for mobile-specific UI. Honestly, you can build a production site using just client:load and client:idle and be fine.
Writing and Using React Components
A React component inside an Astro project looks exactly like any other React component. No special imports, no Astro-specific APIs. You write it in .jsx or .tsx, put it in src/components/, and import it.
// src/components/GlassCard.tsx
import { useState } from 'react';
interface GlassCardProps {
title: string;
description: string;
}
export default function GlassCard({ title, description }: GlassCardProps) {
const [hovered, setHovered] = useState(false);
return (
<div
className={`glass-card ${ hovered ? 'glass-card--active' : '' }`}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
backdropFilter: 'blur(12px)',
background: 'rgba(255,255,255,0.08)',
border: '1px solid rgba(255,255,255,0.15)',
borderRadius: '16px',
padding: '24px',
}}
>
<h2>{title}</h2>
<p>{description}</p>
</div>
);
}Then in your .astro page, you import and render it with a directive:
---
import GlassCard from '../components/GlassCard.tsx';
---
<GlassCard
client:load
title="Island Component"
description="Hydrated on the client, rendered static on the server."
/>If your component doesn't use any React interactivity (no useState, no event handlers), you can actually skip the client:* directive entirely. Astro will render it to static HTML at build time - zero JS for that component in the final bundle. That's a nice optimization for things like static card grids or icon wrappers.
Quick aside: props passed to React components from .astro files have to be serializable. Strings, numbers, plain objects, arrays - all fine. You can't pass a function or a class instance as a prop across the Astro/React boundary. If you need callbacks, keep the entire interactive piece inside a single React component tree.
Sharing State Between Islands
Here's the part people stumble on. Each island is isolated. Two separate <Counter client:load /> instances on the same page don't share React state - they're two separate React roots. That's by design, but it means you need a different strategy when islands need to talk to each other.
The Astro docs recommend nanostores for this. It's a 265-byte state library that's framework-agnostic, which makes it perfect here. You define a store once, import it in any island (React, Svelte, whatever), and reactivity just works across island boundaries.
// src/store/cart.ts
import { atom } from 'nanostores';
export const cartCount = atom<number>(0);// src/components/CartButton.tsx
import { useStore } from '@nanostores/react';
import { cartCount } from '../store/cart';
export default function CartButton() {
const count = useStore(cartCount);
return (
<button onClick={() => cartCount.set(count + 1)}>
Cart ({count})
</button>
);
}That said, if you only need to pass data *down* into a React island (not share state between islands), just use Astro's slot or prop passing. Keep it simple. Reach for nanostores only when two islands actually need bidirectional communication. Look, most pages don't need cross-island state at all - a header cart count and an 'Add to Cart' button on a product page is the canonical example where it matters.
Styling React Components in Astro
Astro's scoped styles use <style> blocks inside .astro files and they don't apply to child React components. That catches people out. Your React component gets rendered into a separate DOM subtree, so Astro's CSS scoping doesn't reach into it.
You've got three good options. First, use global CSS. Any styles in src/styles/global.css (imported in your layout) apply everywhere, including inside React islands. Second, use CSS Modules inside your React component - import a .module.css file and use it normally; Vite handles the transform. Third, use Tailwind. It's utility-first, class-based, and works identically inside React components and .astro files.
If you're pulling in pre-styled components from a library like Empire UI, those components ship with their own styling - you import the component and the styles travel with it. Same pattern as any React project. The glassmorphism components for example expect backdrop-filter support in the browser, so just make sure you're not accidentally stripping those styles through a PostCSS purge configuration.
One more thing - if you're building custom glass or blur effects yourself rather than using a component library, the glassmorphism generator will give you production-ready CSS values instantly. Paste those into your component's inline styles or a CSS Module and you're done.
Performance Patterns and Common Pitfalls
The biggest mistake people make: using client:load for everything. It feels safe because the component always works, but you're basically opting back into SPA behavior. Use it only for above-the-fold interactive UI. Everything else should be client:idle or client:visible.
Second pitfall: importing heavy React libraries (charting libraries, rich text editors, 3D renderers) without code-splitting. Astro handles code splitting automatically per island, but if you import a 180 Kb chart library inside a component tagged client:load, that 180 Kb goes into the critical path. Wrap it in client:idle and it loads after the main content, which is almost always the right call.
Lazy loading React components themselves also works normally - React.lazy() and Suspense behave exactly as they would in a pure React app inside an Astro island:
import { lazy, Suspense } from 'react';
const HeavyEditor = lazy(() => import('./HeavyEditor'));
export default function EditorWrapper() {
return (
<Suspense fallback={<div>Loading editor...</div>}>
<HeavyEditor />
</Suspense>
);
}Worth noting: Astro's build output is static by default. If you need server-side rendering (SSR) for your React components - dynamic data, user sessions, personalization - you'll need to add an SSR adapter (@astrojs/node, @astrojs/vercel, etc.) and set output: 'server' or output: 'hybrid' in your Astro config. In hybrid mode, individual pages opt into SSR while the rest stay static, which is useful if you want one dynamic dashboard route inside an otherwise static marketing site. The gradient generator type of tool - purely client-side, no auth - works without any of that complexity.
FAQ
Yes, fully. Any React component with a client:* directive runs in a real browser React root, so all hooks work normally. Without a directive, the component renders to static HTML server-side - hooks that require the DOM will throw.
No. Run npx astro add react from the Astro app's directory and it installs react and react-dom as dependencies in that package's node_modules. In a Turborepo setup you'd typically hoist them, but no separate config is needed.
By default yes - Astro pre-renders React components to HTML at build time. They only become interactive on the client when you add a client:* directive. A client:only component skips server rendering entirely.
No. Each island is a separate React root, so a Context provider in one island can't supply values to another. Use nanostores or another external store for cross-island state instead.