The Component Model
Components are server-rendered functions
Section titled “Components are server-rendered functions”An Astro component is a .astro file you can import and use in another. It takes props and returns HTML. Because it renders on the server, a component is essentially a function from props to markup — with no client-side lifecycle.
---interface Props { title: string; href: string;}const { title, href } = Astro.props; // props arrive on Astro.props---<a class="card" href={href}> <h3>{title}</h3></a>Using it:
---import Card from '../components/Card.astro';---<Card title="Hello" href="/hello" />Props come in through Astro.props, and you type them by declaring a Props interface in the frontmatter — Astro uses it to type-check usage. There is no useState here; a component runs once to produce HTML.
Leaving out a required prop is a real, checkable type error — not a runtime surprise. Proven with a real astro check against the exact Card component above, called with only title:
---// @expect-error deliberately omits the required `href` prop, proven belowimport Card from '../components/Card.astro';---<Card title="Hello" />$ npx astro checksrc/pages/card-missing-prop.astro:4:2 - error ts(2322): Type '{ title: string; }' is not assignable to type 'IntrinsicAttributes & Props'. Property 'href' is missing in type '{ title: string; }' but required in type 'Props'.
<Card title="Hello" /> ~~~~That’s the practical payoff of typing props at all: a missing (or mistyped) prop is caught before the page ever renders, at the exact call site.
Proof: a component really is a pure function of props
Section titled “Proof: a component really is a pure function of props”If a component is genuinely a function from props to HTML with no hidden state, calling it twice with identical props must produce identical output — no client, no browser, just the render itself. The Astro Container API (experimental_AstroContainer, covered fully in the testing lesson) renders a component to a string in isolation, which makes this directly testable:
import { experimental_AstroContainer as AstroContainer } from 'astro/container';import { expect, test } from 'vitest';import Card from '../components/Card.astro';
test('Card is a pure function of props: same props, same output', async () => { const container = await AstroContainer.create(); const a = await container.renderToString(Card, { props: { title: 'Hello', href: '/hello' } }); const b = await container.renderToString(Card, { props: { title: 'Hello', href: '/hello' } }); expect(a).toBe(b); expect(a).toContain('Hello'); expect(a).toContain('href="/hello"');});$ npx vitest run src/__tests__/card.test.ts ✓ src/__tests__/card.test.ts (1 test) 1 passedTyping props precisely: HTMLAttributes, Polymorphic, ComponentProps
Section titled “Typing props precisely: HTMLAttributes, Polymorphic, ComponentProps”Beyond a hand-written Props interface, astro/types exports three helpers for the props patterns that come up constantly — each verified directly against the installed [email protected]’s types.d.ts:
HTMLAttributes<Tag>— the real, built-in attributes for a given HTML tag, so a wrapper component accepts everything a native element does (onclick,aria-*,data-*, …) without re-declaring them by hand:
---import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'button'> { variant?: 'primary' | 'secondary';}const { variant = 'primary', ...attrs } = Astro.props;---<button class={`btn btn-${variant}`} {...attrs}> <slot /></button>Polymorphic<P>— types a component that can render as a different tag via anasprop (a<Heading as="h2">that needsh2’s real attributes, not a fixed element’s):
---import type { HTMLTag, Polymorphic } from 'astro/types';
type Props<Tag extends HTMLTag> = Polymorphic<{ as: Tag }>;const { as: Tag = 'h2' as const, ...attrs } = Astro.props;---<Tag {...attrs}><slot /></Tag>ComponentProps<T>— extracts the prop type of a function component (a framework island), useful when you want to accept “whatever propsSomeIslandaccepts” without importing and repeating itsPropsinterface:
import type { ComponentProps } from 'astro/types';import type Counter from '../components/Counter.tsx';
type CounterProps = ComponentProps<typeof Counter>;flowchart LR
a["HTMLAttributes<'button'>"] --> u1["inherit a real element's attributes"]
b["Polymorphic<{ as: Tag }>"] --> u2["type an as=... polymorphic component"]
c["ComponentProps<typeof Fn>"] --> u3["reuse a function component's own prop type"]Slots: composition by passing markup
Section titled “Slots: composition by passing markup”A component often needs to wrap content it doesn’t know in advance. That’s what slots are for — the child markup you put between the component’s tags is rendered where <slot /> appears.
---const { heading } = Astro.props;---<section class="panel"> <h2>{heading}</h2> <slot /> <!-- children go here --></section><Panel heading="Notes"> <p>Any markup here lands in the slot.</p></Panel>For more than one insertion point, use named slots:
<header><slot name="header" /></header><main><slot /></main> <!-- the default slot --><footer><slot name="footer" /></footer><Layout> <h1 slot="header">Title</h1> <p>Body goes in the default slot.</p> <small slot="footer">© 2026</small></Layout>flowchart LR parent["Parent passes markup between the tags"] --> slot["Component renders it at <slot />"] slot --> out["composed HTML"]
Slots are the backbone of layouts: a Layout.astro defines the page shell (head, nav, footer) with a <slot /> for the page body, and every page wraps its content in that layout.
Scoped styles
Section titled “Scoped styles”A <style> tag inside a .astro component is scoped to that component by default — Astro rewrites the selectors so they don’t leak out or clash with other components.
<h1>Title</h1><style> /* This h1 rule applies ONLY to this component's h1, not others on the page. */ h1 { color: rebeccapurple; }</style>You get component-local CSS with plain selectors — no CSS-in-JS, no BEM naming ceremony. When you do want a rule to apply globally, you opt out with <style is:global> (covered in the styles lesson).
Reading AI-generated code
Section titled “Reading AI-generated code”A generated .astro component snippet often brings client-framework assumptions with it. Check for these:
- Does it call
useState,useEffect, or any other hook at the top level of the frontmatter? None of that exists in a.astrocomponent’s server-only script — those hooks belong inside a hydrated framework component (an island), not a.astrofile. - Does it declare
Propsbut the template destructures a field that was never in the interface?astro checkcatches an extra required field being missing (proven above withCard) — but an optional field typo’d in the template (hrafinstead ofhref) silently readsundefinedat the template level unless the field itself was also referenced with the wrong name inAstro.props. - Does a
Polymorphic/as-prop component hard-code the rendered tag anywhere in its own logic (e.g. checkingas === 'button'to decide something), defeating the point of making it polymorphic? - Does it use
<slot />inside a component that’s rendered as a framework island (e.g.client:load)? Named/default slots are an Astro-template feature — a framework component’s “slot equivalent” ischildren/named props, not<slot />. - Is a scoped
<style>block assumed to affect a child component’s markup (e.g. styling<Card>’s internal<h3>from the parent)? Scoped styles apply only to selectors matching elements in the same file — reaching into a child requires:global()or a prop-driven class.
🐛 Spot the bug
---interface Props {name: string;role?: 'admin' | 'member';}const { name, role = 'member' } = Astro.props;---<span class={`badge badge-${role}`}>{nam}</span>Show the answer
The template reads {nam} — a typo for {name} — and it is a real astro check error, but not the props-shape error you’d expect from the “missing prop” case above. Proven against this exact snippet:
src/pages/userbadge-test.astro:8:38 - error ts(2552): Cannot find name 'nam'. Did you mean 'name'?
<span class={`badge badge-${role}`}>{nam}</span> ~~~nam isn’t a declared identifier at all, so TypeScript reports ts(2552) (“cannot find name”, with a helpful “did you mean” suggestion) — not a Props-interface mismatch like ts(2322) above. It’s a different class of bug from the missing-required-prop case: that one is wrong props, this one is a typo in the template referencing something that was never destructured. Both are real astro check errors, but for different reasons — this is why it’s worth reading past “does astro check pass” to “does astro check pass for the reason I expect.”
Mental Model
Section titled “Mental Model”Think of a .astro component the way you’d think of a pure server-side template function: render(props) -> html. Props flow in one direction, in through Astro.props; nothing flows back out except the rendered markup. Slots are just a second kind of input — markup instead of data — passed positionally (default) or by name. There’s no lifecycle to reason about because there’s no “later”: the function runs once, returns HTML, and is done. The Container API test above is the literal proof: same input, same output, every time.
Trade-offs
Section titled “Trade-offs”.astro component |
Framework island (.tsx/.vue/…) |
|
|---|---|---|
| Runs | Server only (function of props, no lifecycle) | Server (initial render) and client (if hydrated) |
| State | None — re-runs from scratch every render | useState/reactive state, persists across re-renders |
| Slots | <slot /> / named slots |
children / props, framework-specific |
| Styling | Scoped <style> built in, plain selectors |
Framework’s own convention (CSS modules, styled-components, …) |
| Typing props | Props interface, or HTMLAttributes/Polymorphic/ComponentProps from astro/types |
The framework’s own prop-typing convention |
Common Misconceptions
Section titled “Common Misconceptions”- “A missing required prop is a runtime
undefined, caught (if at all) when the page breaks.” Proven above:astro checkcatches it at the exact call site, before the page ever renders —ts(2322), “Property ‘href’ is missing.” - “
.astrocomponents can hold state between renders, like a class with instance fields.” They can’t — proven by the Container API test above: identical props always produce byte-identical output, because the component is re-run from scratch every time, with no persistence. - “
HTMLAttributes<'button'>is just{ [key: string]: any }.” It’s the real, specific attribute set for that tag (verified againstastro/types.d.ts) — passing an attribute that doesn’t exist on<button>(like a rawhref) is still a type error.