Typing Astro Components for a Better Developer Experience

Astro ships with TypeScript out of the box, yet many component systems never take advantage of it beyond the defaults. That’s a missed opportunity. A few type declarations in your component props can turn a component library from something developers need to read source code to understand into something they can use correctly on the first try.

The goal here is simple: build a heading component that gives developers clear choices — which HTML element to render, what size and weight to use — while preventing mistakes that break semantics or accessibility. Along the way, we’ll rely on TypeScript’s editor feedback (the “red squiggles”) to guide developers toward valid usage.

Getting Started

Create a new Astro project using the “Minimal” template, then remove the boilerplate <Welcome /> component from your index route for a clean slate.

npm create astro@latest

For styling, add Tailwind to the project. This isn’t strictly necessary, but it makes the class mapping we’ll set up more practical.

npx astro add tailwind

Building the Heading Component

We want developers to choose an HTML heading level (h1h6), a font size, and a font weight. Crucially, these are separate concerns: picking an h1 shouldn’t force a specific visual style. We also want any extra HTML attributes to pass through, since nothing is more frustrating than a component that blocks basic functionality.

Dynamic Tags

Create ./src/components/Heading.astro. Astro requires dynamic element names to start with a capital letter, so we’ll take that burden off the developer by converting the prop internally.

---
// ./src/component/Heading.astro
const { as } = Astro.props;
const As = as;
---

<As>
  <slot />
</As>

Wrap the content in the dynamically named component using Astro’s <slot />.

---

Now the component can render any element passed via the as prop. Import it into your index route and try it with both h1 and h2.

---
// ./src/pages/index.astro
import Layout from '../layouts/Layout.astro';
import Heading from '../components/Heading.astro';

---

<Layout>
  <Heading as="h1">Hello!</Heading>
  <Heading as="h2">Hello world</Heading>
</Layout>
Showing the h1 and h3 elements inspected in DevTools.

Adding Custom Props

Move the element selector into the Astro.props destructuring with a sensible default so developers get a heading even if they forget the prop.

---
// ./src/component/Heading.astro
const { as: As="h2" } = Astro.props;
---

<As>
  <slot />
</As>

Next, add size and weight props. Rather than forcing developers to know Tailwind class names, map a small declarative set of options (from sm to 6xl for size; from light to bold for weight) to the actual class strings. This keeps the API stable even if the underlying styling system changes, and it sidesteps Tailwind’s limitation with dynamically constructed class names.

---
// ./src/component/Heading.astro

const weights = {
    "bold": "font-bold",
    "semibold": "font-semibold",
    "medium": "font-medium",
    "light": "font-light"
}
const sizes= {
    "6xl": "text-6xl",
    "5xl": "text-5xl",
    "4xl": "text-4xl",
    "3xl": "text-3xl",
    "2xl": "text-2xl",
    "xl": "text-xl",
    "lg": "text-lg",
    "md": "text-md",
    "sm": "text-sm"
}

const { as: As="h2", weight="medium", size="2xl" } = Astro.props;
---

Apply the mapped classes using Astro’s class:list directive, which accepts arrays of strings, objects, or variables.

---
// ./src/component/Heading.astro

const weights = {
  bold: "font-bold",
  semibold: "font-semibold",
  medium: "font-medium",
  light: "font-light",
};
const sizes = {
  "6xl": "text-6xl",
  "5xl": "text-5xl",
  "4xl": "text-4xl",
  "3xl": "text-3xl",
  "2xl": "text-2xl",
  xl: "text-xl",
  lg: "text-lg",
  md: "text-md",
  sm: "text-sm",
};

const { as: As = "h2", weight = "medium", size = "2xl" } = Astro.props;
---

<As class:list={[
  sizes[size], 
  weights[weight]
]}
>
  <slot />
</As>

Your page should update immediately, and you’ll see the applied classes in the developer tools. Update the route with the new props to test different configurations.

---
// ./src/pages/index.astro
import Layout from '../layouts/Layout.astro';
import Heading from '../components/Heading.astro';
---

<Layout>
  <Heading as="h1" size="6xl" weight="light">Hello!</Heading>
  <Heading as="h3" size="xl" weight="bold">Hello world</Heading>
</Layout>
Showing the h1 and h3 elements inspected in DevTools revealing the applied classes.

Passing Through HTML Attributes

To let developers add any other attribute, spread the remaining props onto the element.

---
// ./src/component/Heading.astro

const weights = {
  // etc.
};
const sizes = {
  // etc.
};

const { as: As = "h2", weight = "medium", size = "md", ...attrs } = Astro.props;
---

<As class:list={[
  sizes[size], 
  weights[weight]
]}
{...attrs}
>
  <slot />
</As>

Now arbitrary attributes work. Notably, when a class is passed alongside the class:list directive, Astro merges them automatically — no extra code needed.

---
// ./src/pages/index.astro
import Layout from '../layouts/Layout.astro';
import Heading from '../components/Heading.astro';

---

<Layout>
  <Heading id="my-id" as="h1" size="6xl" weight="light">Hello!</Heading>
  <Heading class="text-blue-500" as="h3" size="xl" weight="bold">Hello world</Heading>
</Layout>
Showing the h1 and h3 elements inspected in DevTools.

The Hidden Risks

At this point the component appears functional, but two problems remain. First, type errors are showing in the editor because size and weight are implicitly any rather than constrained types. Second, developers can pass anything as the as prop — including a div — which would silently break the page’s heading semantics.

Showing the div and h3 elements inspected in DevTools.

The rendered output looks identical with a div, but there’s no longer an h1 on the page. That’s a serious problem for SEO and screen readers. Typing the props eliminates this class of mistake before code is ever pushed.

Adding Types

Astro provides HTML attribute types we can extend. Import the ones for an h1 element, then constrain the as prop to a union of heading levels.

---
// ./src/component/Heading.astro
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'h1'> {
  as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
}

//... The rest of the file
---

Immediately, the editor flags as="div" in the route and offers a dropdown of valid options when you clear the value. That feedback scales to the more custom props, where the choices are less obvious.

Showing a contextual menu that displays all of heading level options for the heading component while the code is being typed.

For size and weight, we could hardcode the string unions, but that creates a maintenance burden: every new option would require updating the type. Instead, use TypeScript’s keyof typeof to derive the literal types directly from the mapping objects. If the object changes, the type follows automatically.

---
// ./src/component/Heading.astro
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'h1'> {
  as: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
  weight?: keyof typeof weights;
  size?: keyof typeof sizes;
}

// ... The rest of the file

Developers now get autocomplete suggestions for sizes and weights, and invalid values produce a clear editor error.

Showing a contextual menu that displays all of the size options for the heading component while the code is being typed.

None of this requires extra tooling or configuration — Astro includes TypeScript by default. The investment is a few minutes per component, and the payoff is a component system that developers (including your future self) can pick up without reading the source code first.