Skip to content

The Component Model

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.

src/components/Card.astro
---
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 below
import Card from '../components/Card.astro';
---
<Card title="Hello" />
$ npx astro check
src/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:

src/__tests__/card.test.ts
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 passed

Typing 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:
src/components/Button.astro
---
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 an as prop (a <Heading as="h2"> that needs h2’s real attributes, not a fixed element’s):
src/components/Heading.astro
---
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 props SomeIsland accepts” without importing and repeating its Props interface:
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"]
Three ways to type props beyond a plain interface

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.

src/components/Panel.astro
---
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:

Layout.astro
<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 let a parent inject markup into a component

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.

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).

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 .astro component’s server-only script — those hooks belong inside a hydrated framework component (an island), not a .astro file.
  • Does it declare Props but the template destructures a field that was never in the interface? astro check catches an extra required field being missing (proven above with Card) — but an optional field typo’d in the template (hraf instead of href) silently reads undefined at the template level unless the field itself was also referenced with the wrong name in Astro.props.
  • Does a Polymorphic/as-prop component hard-code the rendered tag anywhere in its own logic (e.g. checking as === '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” is children/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

src/components/UserBadge.astro
---
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.”

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.

.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
  • “A missing required prop is a runtime undefined, caught (if at all) when the page breaks.” Proven above: astro check catches it at the exact call site, before the page ever renders — ts(2322), “Property ‘href’ is missing.”
  • “.astro components 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 against astro/types.d.ts) — passing an attribute that doesn’t exist on <button> (like a raw href) is still a type error.
How does an Astro component receive its props?
What is a slot used for?
By default, where does a <style> block in a .astro component apply?
How do you provide multiple insertion points in one component?
Used as <Card title="Hello" /> (no href). What does a real astro check report?
---
// src/components/Card.astro
interface Props {
  title: string;
  href: string;
}
const { title, href } = Astro.props;
---
<a class="card" href={href}><h3>{title}</h3></a>
This test passed for real. What does that prove about a .astro component?
import { experimental_AstroContainer as AstroContainer } from 'astro/container';
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);