I’m an enormous fan of Astro’s give attention to developer expertise (DX) and the onboarding of recent builders. Whereas the essential DX is powerful, I can simply make a convoluted system that’s onerous to onboard my very own builders to. I don’t need that to occur.
If I’ve a number of builders engaged on a undertaking, I would like them to know precisely what to anticipate from each part that they’ve at their disposal. This goes double for myself sooner or later once I’ve forgotten the best way to work with my very own system!
To try this, a developer may go learn every part and get a robust grasp of it earlier than utilizing one, however that feels just like the onboarding could be extremely sluggish. A greater manner could be to arrange the interface in order that because the developer is utilizing the part, they’ve the best information instantly accessible. Past that, it could bake in some defaults that don’t enable builders to make expensive errors and alerts them to what these errors are earlier than pushing code!
Enter, in fact, TypeScript. Astro comes with TypeScript arrange out of the field. You don’t have to make use of it, however because it’s there, let’s discuss the best way to use it to craft a stronger DX for our growth groups.
Watch
I’ve additionally recorded a video model of this text which you could watch if that’s your jam. Test it out on YouTube for chapters and closed captioning.
Setup
On this demo, we’re going to make use of a primary Astro undertaking. To get this began, run the next command in your terminal and select the “Minimal” template.
npm create astro@newest
This may create a undertaking with an index route and a quite simple “Welcome” part. For readability, I like to recommend eradicating the
part from the path to have a clear start line to your undertaking.
So as to add a little bit of design, I’d suggest organising Tailwind for Astro (although, you’re welcome to model your part nevertheless you prefer to together with a mode block within the part).
npx astro add tailwind
As soon as that is full, you’re prepared to put in writing your first part.
Creating the essential Heading part
Let’s begin by defining precisely what choices we wish to present in our developer expertise.
For this part, we wish to let builders select from any HTML heading stage (H1-H6). We additionally need them to have the ability to select a particular font measurement and font weight — it might appear apparent now, however we don’t need individuals selecting a particular heading stage for the load and font measurement, so we separate these considerations.
Lastly, we wish to guarantee that any further HTML attributes will be handed by to our part. There are few issues worse than having a part after which not having the ability to do primary performance later.
Utilizing Dynamic tags to create the HTML component
Let’s begin by making a easy part that enables the consumer to dynamically select the HTML component they wish to use. Create a brand new part at ./src/elements/Heading.astro
.
---
// ./src/part/Heading.astro
const { as } = Astro.props;
const As = as;
---
To make use of a prop as a dynamic component identify, we want the variable to begin with a capital letter. We are able to outline this as a part of our naming conference and make the developer at all times capitalize this prop of their use, however that feels inconsistent with how most naming works inside props. As a substitute, let’s preserve our give attention to the DX, and take that burden on for ourselves.
In an effort to dynamically register an HTML component in our part, the variable should begin with a capital letter. We are able to convert that within the frontmatter of our part. We then wrap all the kids of our part within the
part by utilizing Astro’s built-in
part.
Now, we are able to use this part in our index route and render any HTML component we wish. Import the part on the prime of the file, after which add and
---
// ./src/pages/index.astro
import Format from '../layouts/Format.astro';
import Heading from '../elements/Heading.astro';
---
Hiya!
Hiya world

This may render them appropriately on the web page and is a good begin.
Including extra customized props as a developer interface
Let’s clear up the component selecting by bringing it inline to our props destructuring, after which add in further props for weight, measurement, and any further HTML attributes.
To start out, let’s deliver the customized component selector into the destructuring of the Astro.props
object. On the identical time, let’s set a wise default in order that if a developer forgets to move this prop, they nonetheless will get a heading.
---
// ./src/part/Heading.astro
const { as: As="h2" } = Astro.props;
---
Subsequent, we’ll get weight and measurement. Right here’s our subsequent design selection for our part system: can we make our builders know the category names they should use or do we offer a generic set of sizes and do the mapping ourselves? Since we’re constructing a system, I feel it’s essential to maneuver away from class names and right into a extra declarative setup. This may also future-proof our system by permitting us to vary out the underlying styling and sophistication system with out affecting the DX.
Not solely can we future proof it, however we are also capable of get round a limitation of Tailwind by doing this. Tailwind, because it seems can’t deal with dynamically-created class strings, so by mapping them, we resolve an instantaneous problem as properly.
On this case, our sizes will go from small (sm
) to 6 instances the dimensions (6xl
) and our weights will go from “mild” to “daring”.
Let’s begin by adjusting our frontmatter. We have to get these props off the Astro.props
object and create a pair objects that we are able to use to map our interface to the right class construction.
---
// ./src/part/Heading.astro
const weights = {
"daring": "font-bold",
"semibold": "font-semibold",
"medium": "font-medium",
"mild": "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", measurement="2xl" } = Astro.props;
---
Relying in your use case, this quantity of sizes and weights could be overkill. The beauty of crafting your individual part system is that you simply get to decide on and the one limitations are those you set for your self.
From right here, we are able to then set the lessons on our part. Whereas we may add them in a typical class
attribute, I discover utilizing Astro’s built-in class:record
directive to be the cleaner strategy to programmatically set lessons in a part like this. The directive takes an array of lessons that may be strings, arrays themselves, objects, or variables. On this case, we’ll choose the right measurement and weight from our map objects within the frontmatter.
---
// ./src/part/Heading.astro
const weights = {
daring: "font-bold",
semibold: "font-semibold",
medium: "font-medium",
mild: "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", measurement = "2xl" } = Astro.props;
---
Your front-end should automatically shift a little in this update. Now your font weight will be slightly thicker and the classes should be applied in your developer tools.

From here, add the props to your index route, and find the right configuration for your app.
---
// ./src/pages/index.astro
import Layout from '../layouts/Layout.astro';
import Heading from '../components/Heading.astro';
---
Hello!
Hello world

Our custom props are finished, but currently, we can’t use any default HTML attributes, so let’s fix that.
Adding HTML attributes to the component
We don’t know what sorts of attributes our developers will want to add, so let’s make sure they can add any additional ones they need.
To do that, we can spread any other prop being passed to our component, and then add them to the rendered component.
---
// ./src/component/Heading.astro
const weights = {
// etc.
};
const sizes = {
// etc.
};
const { as: As = "h2", weight = "medium", size = "md", ...attrs } = Astro.props;
---
From here, we can add any arbitrary attributes to our element.
---
// ./src/pages/index.astro
import Layout from '../layouts/Layout.astro';
import Heading from '../components/Heading.astro';
---
Hello!
Hello world
I’d like to take a moment to truly appreciate one aspect of this code. Our , we add an
id
attribute. No big deal. Our
class:list
set in our component. Astro takes that worry away. When the class is passed and added to the component, Astro knows to merge the class prop with the class:list
directive and automatically makes it work. One less line of code!

In many ways, I like to consider these additional attributes as “escape hatches” in our component library. Sure, we want our developers to use our tools exactly as intended, but sometimes, it’s important to add new attributes or push our design system’s boundaries. For this, we allow them to add their own attributes, and it can create a powerful mix.
It looks done, but are we?
At this point, if you’re following along, it might feel like we’re done, but we have two issues with our code right now: (1) our component has “red squiggles” in our code editor and (2) our developers can make a BIG mistake if they choose.
The red squiggles come from type errors in our component. Astro gives us TypeScript and linting by default, and sizes and weights can’t be of type: any
. Not a big deal, but concerning depending on your deployment settings.
The other issue is that our developers don’t have to choose a heading element for their heading. I’m all for escape hatches, but only if they don’t break the accessibility and SEO of my site.
Imagine, if a developer used this with a div
instead of an h1
on the page. What would happen?We don’t have to imagine, make the change and see.

It looks identical, but now there’s no element on the page. Our semantic structure is broken, and that’s bad news for many reasons. Let’s use typing to help our developers make the best decisions and know what options are available for each prop.
Adding types to the component
To set up our types, first we want to make sure we handle any HTML attributes that come through. Astro, again, has our backs and has the typing we need to make this work. We can import the right HTML attribute types from Astro’s typing package. Import the type and then we can extend that type for our own props. In our example, we’ll select the h1
types, since that should cover most anything we need for our headings.
Inside the Props
interface, we’ll also add our first custom type. We’ll specify that the as
prop must be one of a set of strings, instead of just a basic string type. In this case, we want it to be h1
–h6
and nothing else.
---
// ./src/component/Heading.astro
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'h1'> "h6";
//... The rest of the file
---
After adding this, you’ll note that in your index route, the component should now have a red underline for the
as="div"
property. When you hover over it, it will let you know that the as
type does not allow for div
and it will show you a list of acceptable strings.
If you delete the div
, you should also now have the ability to see a list of what’s available as you try to add the string.

While it’s not a big deal for the element selection, knowing what’s available is a much bigger deal to the rest of the props, since those are much more custom.
Let’s extend the custom typing to show all the available options. We also denote these items as optional by using the ?:
before defining the type.
While we could define each of these with the same type functionality as our as
type, that doesn’t keep this future proofed. If we add a new size or weight, we’d have to make sure to update our type. To solve this, we can use a fun trick in TypeScript: keyof typeof
.
There are two helper functions in TypeScript that will help us convert our weights and sizes object maps into string literal types:
typeof
: This helper takes an object and converts it to a type. For instancetypeof weights
would returntype { bold: string, semibold: string, ...etc}
keyof
: This helper function takes a type and returns a list of string literals from that type’s keys. For instancekeyof type { bold: string, semibold: string, ...etc}
would return"bold" | "semibold" | ...etc
which is exactly what we want for both weights and sizes.
---
// ./src/component/Heading.astro
import type { HTMLAttributes } from 'astro/types';
interface Props extends HTMLAttributes<'h1'> "h3"
// ... The rest of the file
Now, when we want to add a size or weight, we get a dropdown list in our code editor showing exactly what’s available on the type. If something is selected, outside the list, it will show an error in the code editor helping the developer know what they missed.

While none of this is necessary in the creation of Astro components, the fact that it’s built in and there’s no additional tooling to set up means that using it is very easy to opt into.
I’m by no means a TypeScript expert, but getting this set up for each component takes only a few additional minutes and can save a lot of time for developers down the line (not to mention, it makes onboarding developers to your system much easier).