Astro components, explained properly
A component that ships no JavaScript sounds like a trick. It is not — it is just a template that runs once, at build time, and then gets out of the way.
If you arrive at Astro from React or Vue, the first .astro file looks familiar and then quietly refuses to behave like one. There is no useState, no lifecycle, no re-render. That absence is the whole point, and it is worth understanding before you fight it.
A component is a template that runs once
An .astro file has two parts: frontmatter between two --- fences, and markup below it. The frontmatter is plain JavaScript that runs on the server during the build. The markup is HTML with expressions. When the build finishes, the frontmatter is gone and what remains is static HTML.
---
interface Props {
title: string;
subtitle?: string;
}
const { title, subtitle } = Astro.props;
---
<section class="hero">
<h1>{title}</h1>
{subtitle && <p>{subtitle}</p>}
</section>That is a complete, production-ready component. No imports, no runtime, no hydration. Used on a page, it renders to three lines of HTML and contributes exactly zero bytes of JavaScript.
Props are the only input
Astro.props is how content gets in, and typing it with a Props interface is worth the ten seconds. Your editor then autocompletes every prop at the call site, and a typo becomes a build error rather than an empty heading in production.
The pattern that scales is to give every prop a sensible default and let the caller override what matters. A block with eight optional props and two required ones is pleasant to reuse; a block with ten required props is a form you have to fill in every time.
Styles are scoped by default
A <style> tag inside a component applies only to that component. Astro adds a data attribute to the elements and rewrites your selectors to match, so a .title class in one file cannot leak into another. You get the isolation of CSS modules without importing anything or renaming a class.
<style>
.hero { padding: clamp(3rem, 7vw, 6rem) 0; }
.hero h1 { font-size: clamp(2rem, 5vw, 3rem); }
</style>When you deliberately want to reach outside — a design token, a shared button class — use <style is:global> in a layout, or define the token as a CSS custom property on :root. Keeping a small set of global tokens and scoping everything else is the balance that holds up over hundreds of components.
Scoped by default, global on purpose. That single rule prevents most CSS regressions in a large component set.
When you do need JavaScript
A plain <script> tag in an .astro file is bundled and shipped, and it runs once on page load. That is enough for the majority of interface work: toggling a class, switching a tab panel, opening a menu. It is not a framework and does not need to be.
<script>
document.querySelectorAll('[data-tabs]').forEach((box) => {
const tabs = box.querySelectorAll('.tab');
tabs.forEach((tab) => tab.addEventListener('click', () => {
tabs.forEach((t) => t.setAttribute('aria-selected', String(t === tab)));
}));
});
</script>Reach for a framework component with a client: directive only when you genuinely need reactive state that survives interaction — a live search, a cart, a canvas. Everything above that line is a class toggle wearing a costume.
The trade-off worth naming
Astro components cannot re-render. If a value changes after load, you update the DOM yourself or you hydrate an island. For marketing pages, documentation and content sites that constraint costs you almost nothing and buys you a page that is fast before you optimise a single thing.
For an app with genuinely reactive state on every screen, the constraint becomes friction. Knowing which of the two you are building is more useful than any benchmark.