
What is the difference between Astro components and framework components?
The difference between Astro components and framework components is where they run. An Astro component (.astro) renders to HTML on the server, at build time or on request, and ships no JavaScript runtime to the browser; any interactivity has to come from a plain <script> tag. A framework component (React, Vue, Svelte, Solid, Preact and others) also renders to HTML on the server by default, but you can add a client:* directive to send its JavaScript to the browser and hydrate it into an interactive island with state, events and re-rendering. Use Astro components for layout and content, and framework components only for the parts of a page that need client-side state.
Both kinds of component can live side by side in src/components/, and from a page they look almost identical: you import them and write them as tags. The differences show up in what they can do, what they cost and how they pass data. In this article you'll see a side-by-side comparison, the same counter built both ways, what the browser actually receives, how props and slots differ across the boundary, which nesting is allowed, and a practical guide to choosing.
Side-by-Side Comparison
| Astro component | Framework component | |
|---|---|---|
| File type | .astro | .jsx, .tsx, .vue, .svelte and so on |
| Setup | Built in | Needs an integration, such as @astrojs/react |
| Where it renders | Server only | Server by default, plus the browser when hydrated |
| JavaScript sent by default | None | None |
| Can be interactive | Only through <script> tags | Yes, with a client:* directive |
| State and re-rendering | No, renders once | Yes, after hydration |
| Props | Any JavaScript value, including functions | Must be serialisable when hydrated |
| Children | <slot /> and named slots | children and props (React), <slot> (Vue, Svelte) |
| Can import the other kind | Yes, can import framework components | No, cannot import .astro files |
| Server-only code (secrets, database) | Safe in the component script | Code may be bundled for the browser when hydrated |
The key row is "JavaScript sent by default". Neither sends any JavaScript unless you ask. A framework component without a directive renders to static HTML, just like an Astro component. The difference is that a framework component can be hydrated, and an Astro component can't. Why that default matters for performance is covered in how Astro ships zero JavaScript by default.
The Same Counter, Built Both Ways
A counter shows the difference clearly. First, as a React component:
// src/components/Counter.tsx
import { useState } from "react";
interface Props {
initial?: number;
label: string;
}
export default function Counter({ initial = 0, label }: Props) {
const [count, setCount] = useState(initial);
return (
<button type="button" onClick={() => setCount(count + 1)}>
{label}: {count}
</button>
);
}
The React integration is added with npx astro add react. Now the same thing as an Astro component, with a script for the behaviour:
---
// src/components/AstroCounter.astro
interface Props {
label: string;
}
const { label } = Astro.props;
---
<button type="button" class="astro-counter" data-label={label}>
{label}: <span>0</span>
</button>
<script>
document.querySelectorAll<HTMLButtonElement>(".astro-counter").forEach((button) => {
const output = button.querySelector("span")!;
let count = 0;
button.addEventListener("click", () => {
count += 1;
output.textContent = String(count);
});
});
</script>
Using both on a page:
---
// src/pages/index.astro
import Counter from "../components/Counter.tsx";
import AstroCounter from "../components/AstroCounter.astro";
---
<Counter label="Static" />
<Counter label="Interactive" initial={5} client:visible />
<AstroCounter label="Astro" />
<AstroCounter label="Astro two" />
All four buttons render as HTML. Only three of them work when clicked: the first React counter has no directive, so it's plain HTML with no event handler attached.
What the Browser Receives
I built that page and looked at the output. The differences are instructive.
The static React counter became ordinary HTML:
<button type="button">Static<!-- -->: <!-- -->0</button>
The hydrated React counter was wrapped in an <astro-island> custom element that records where its code lives and what props to hydrate with:
<astro-island uid="2i2Yup" prefix="r5" component-url="/_astro/Counter.Dao5TgHo.js" component-export="default" renderer-url="/_astro/client.CzWaolnw.js" props="{"label":[0,"Interactive"],"initial":[0,5]}" ssr client="visible" ...><button type="button">Interactive<!-- -->: <!-- -->5</button><!--astro:end--></astro-island>
Along with it came a small inline script for the island loader and the client:visible strategy, plus the React runtime as a separate file. In this build, the React client chunk was about 213 kB uncompressed (about 66 kB gzipped), while the counter's own chunk was 261 bytes. That runtime is downloaded once per page however many React islands you use, but it's the main cost of a framework island.
The Astro counter rendered as HTML with one small script. Even though the component appeared twice, the script was included only once, because Astro processes and deduplicates <script> tags:
<button type="button" class="astro-counter" data-label="Astro">Astro: <span>0</span></button><script type="module">document.querySelectorAll(`.astro-counter`).forEach(e=>{let t=e.querySelector(`span`),n=0;e.addEventListener(`click`,()=>{n+=1,t.textContent=String(n)})});</script><button type="button" class="astro-counter" data-label="Astro two">Astro two: <span>0</span></button>
The script was small enough that Astro inlined it into the page. For a counter, the Astro version costs a few hundred bytes and the React version tens of kilobytes. For a complex, stateful UI, the framework's model pays for itself; for a toggle or a counter, it usually doesn't.
Hydration Directives Only Work on Framework Components
The client:* directives control when an island's JavaScript loads:
- client:load: as soon as the page loads, for UI that must be interactive immediately.
- client:idle: once the browser is idle, for lower-priority UI.
- client:visible: when the component scrolls into view.
- client:media: when a CSS media query matches, such as
client:media="(max-width: 50em)". - client:only: skip server rendering and render only in the browser, naming the framework, such as
client:only="react".
Putting a directive on an Astro component does nothing useful. When I tried <AstroCounter label="x" client:load />, the build still succeeded but logged:
You are attempting to render <AstroCounter client:load />, but AstroCounter is an Astro component. Astro components do not render in the client and should not have a hydration directive. Please use a framework component for client rendering.
The directives are covered one by one in Astro client directives explained.
Props Across the Boundary
Between Astro components, a prop can be any JavaScript value, because everything happens in one server process. Hydrated framework components are different: their props must be written into the HTML (you can see them in the props attribute above) and rebuilt in the browser. Astro's documentation lists the supported types as plain objects, number, string, Array, Map, Set, RegExp, Date, BigInt, URL, Uint8Array, Uint16Array, Uint32Array and Infinity.
Functions are the notable gap. You can't pass an onSave callback from an Astro page to a hydrated React component and expect it to run in the browser. Keep event handlers inside the framework component, or communicate between islands through shared state, as described in how to share state between islands.
Also remember that props of a hydrated component are visible to anyone who views the page source. Don't pass secrets or whole database records when the island only needs a couple of fields.
Children and Slots Across the Boundary
From an .astro file you can pass children to a framework component. How the component receives them depends on the framework:
- React, Preact and Solid: the default slot arrives as
children, and each named slot arrives as a prop, withkebab-casenames converted tocamelCase. - Vue and Svelte: they use their own
<slot>elements, and slot names keep their original form.
Here's a React sidebar with named slots:
// src/components/Sidebar.tsx
import type { ReactNode } from "react";
interface Props {
title?: ReactNode;
socialLinks?: ReactNode;
children?: ReactNode;
}
export default function Sidebar({ title, socialLinks, children }: Props) {
return (
<aside>
<header>{title}</header>
<div>{children}</div>
<footer>{socialLinks}</footer>
</aside>
);
}
<Sidebar>
<h2 slot="title">Menu</h2>
<p>Sidebar body.</p>
<ul slot="social-links"><li>GitHub</li></ul>
</Sidebar>
The social-links slot became the socialLinks prop, and the output was:
<aside><header><h2>Menu</h2></header><div><p>Sidebar body.</p></div><footer><ul><li>GitHub</li></ul></footer></aside>
Slots in Astro components are covered in what slots are and how to use named slots.
Nesting Rules
The rules about which component can contain which are strict, and they explain a lot of error messages:
- Astro components can import framework components, from any number of frameworks on the same page.
- Framework components cannot import Astro components. A
.tsxor.sveltefile must be valid code for its own framework, and.astrofiles aren't. - You can still put Astro output inside a framework component, by passing it as a child from an
.astrofile. The Astro component renders on the server and its HTML is handed to the island as slot content. - A framework file can't mix frameworks. Combine React and Vue in an
.astrofile, not inside one of them.
Rule 3 looks like this:
---
// src/pages/index.astro
import Sidebar from "../components/Sidebar.tsx";
import Callout from "../components/Callout.astro";
---
<Sidebar client:idle>
<Callout slot="title">Rendered by Astro</Callout>
<p>Hydrated body.</p>
</Sidebar>
In the build, the Astro component's HTML was placed inside the island in an <astro-slot> element:
<aside><header><astro-slot name="title"><div class="callout callout-note">Rendered by Astro</div></astro-slot></header>...
The callout is static HTML. The React component can move it around but can't change what's inside it, because the Astro component never runs in the browser.
Which Should You Use?
Start with an Astro component and only switch when you need something it can't do.
Choose an Astro component for:
- Layouts, headers, footers and page sections: anything that's mostly markup.
- Content rendering: blog posts, cards, lists, tables and navigation.
- Server-side data: components that fetch from an API or database, read environment secrets or query content collections.
- Light interactivity: menus, toggles, copy buttons and tabs that a short
<script>can handle. See interactivity without a framework.
Choose a framework component for:
- Complex state: forms with validation, filters that update a list, multi-step flows.
- Frequent re-rendering: live search, charts that update, drag-and-drop.
- Existing component libraries: when you already have React or Vue components, or need a library built for one framework.
- Shared code with another app: a design system that's also used in a separate single-page app.
A common, healthy pattern is an Astro page made of Astro components, with one or two framework islands for the genuinely interactive parts, each with the laziest directive that still works, such as client:visible for anything below the fold.
FAQ: Astro Components vs Framework Components
No. Astro components render to HTML on the server and have no client-side runtime. The only JavaScript they send is from script tags you write yourself, which Astro bundles and deduplicates.
No. Without a client directive, a React, Vue or Svelte component is rendered to static HTML on the server and no JavaScript is sent. Only components with a client directive are hydrated.
No. Astro components can't be hydrated. The build logs a warning and renders the component as normal HTML. Use a script tag inside the component, or rewrite it as a framework component.
No. Framework component files can only import code for their own framework. You can pass an Astro component as a child or named slot to a framework component from inside an .astro file instead.
Only if the component isn't hydrated. Props of hydrated components are serialised into the HTML, and functions can't be serialised, so keep event handlers inside the framework component.
Yes. Install both integrations and import components from each into an .astro file. Each framework's runtime is only sent if at least one of its components is hydrated on that page.
Conclusion
Astro components and framework components look the same in a template, but they live in different places. Astro components exist only on the server: they can use any data, any secret and any JavaScript value, and they turn into HTML with no runtime. Framework components also render on the server, but with a client:* directive they continue running in the browser, bringing state, events and re-rendering at the cost of the framework's runtime and serialisable props.
The practical rule is to build pages from Astro components and add framework islands only where the interface genuinely needs client-side state. Pass data down as serialisable props, pass Astro-rendered markup in through slots, and remember that the nesting only goes one way: Astro files can contain framework components, never the reverse.


