A black cat in front of a laptop computer, reading this blog.

November 27, 2023

Making a blog with Astro and TailwindCSS


Introduction

In this article, I’ll walk you through how I created this blog, share my design choices and discuss the implementation of some notable features. I provided all the links to the relevant documentation so feel free to follow this as a high-level tutorial if you want.

Table of Contents

Why Astro?

There are a ton of web frontend frameworks and ultimately the overlap between their features is significant. Here’s a quick rundown of why I ended up choosing Astro.

The two big technical requirements I was looking for were:

  1. Lightweight. No Javascript shipped to the user.
  2. Easy to build and maintain.

I also wanted to make this a learning experience, so no premade templates, WordPress or other ready-made solutions. That said I did want something slick and modern, so no bespoke raw HTML files either.

I had written a tiny bit of Svelte in the past, and I enjoyed the experience.

Hazel being happy
Svelte? Like me?

No, silly :) Svelte the reactive web UI framework! Besides, I’ve been giving you way too much food recently… Anyway!

Astro first caught my eye because it looked similar to Svelte/SvelteKit in terms of tooling and DX but with a built-in site generator focused on lightweight static sites. By default, Astro ships fully static HTML (no JS) which is perfect for my use case. That said, Astro uses a simple yet powerful feature called reactive islands to enable framework-agnostic reactivity. The name comes from the fact that islands are isolated reactive nodes embedded in a sea of otherwise static content.

Reactivity in Astro is not tied to one specific framework. Instead, islands let you bring your own reactivity framework, or even use multiple different ones. This means I could’ve used Svelte components for this site (although I chose not to in this case). The maintenance implications of this are also huge: if the reactivity framework ever changes, pages can easily be migrated one at a time. Or you can just take advantage of the strengths of multiple frameworks depending on which better fits the page’s content.

Components are the main building blocks to build Astro sites. In a nutshell, components let you define your own reusable custom HTML tags using templates. That’s great because it makes it easy to keep code DRY and provides an intuitive abstraction to build a site upon. Don’t worry if you’re not familiar, I’ll get back to this in a minute. A cool thing about Astro components is that while they can contain JavaScript expressions for templating, they are all resolved at build time. The user never sees them and receives a completely static piece of HTML.

On the tooling front, Astro leverages the Vite toolkit to provide optimized builds and a snappy dev server with hot reloading. In addition, the CLI makes it easy to create, check, build, and add integrations to the project. Overall the tooling felt solid and user-friendly.

In my opinion, strong static typing is an integral part of software maintainability. A rich type system helps encode invariants and is immensely useful to prevent mistakes and make programs easier to reason about. Fortunately, Astro has built-in support for TypeScript, both in template expressions and component properties.

An image featuring the Astro logo

Astro first caught my eye because it looked similar to Svelte/SvelteKit in terms of tooling and DX but with a built-in site generator focused on lightweight static sites.

With the previous points in mind, Astro seemed like a good match. It fulfills my two technical requirements:

  1. Astro focuses on static content and ships no JavaScript by default.
  2. Astro has amazing dev tools built on the shoulder of Vite and provides maintainability through its component-based model and typescript support.

In addition, it felt like a natural choice due to it having similar characteristics to Svelte and SvelteKit, which I had already used in the past.

Getting started with Astro

Creating an Astro project is a breeze. I ran the installation command and completed the CLI wizard:

Terminal
npm create astro@latest

I chose an empty template because I wanted to work my way up to understand how every part of an Astro project worked, but there are also fully fleshed-out templates available. Of course, I also enabled TypeScript with its strictest settings.

With the project skeleton created, this might be a good time to take a look at Astro’s project structure. The structure is pretty flexible, but some notable directories are

Aside from these, Astro lets you organize your files however you like, although there are conventions. All in all, my folder structure ended up looking something like this:

.
├── public
└── src
├── assets
├── components
├── content
│ └── blog
├── layouts
└── pages
└── blog

Astro components

Astro components are formed of two distinct parts: a script and a template. The component script is a block of TypeScript located at the top of the file and delimited by fences. The rest is the template and it’s written in a JSX-like language.

Here’s a tiny example of how components work in practice:

src/components/Title.astro
---
// This is the script
let title = "Meow";
---
<!-- This is the template -->
<h1>{title}</h1>

Components can receive props by defining a Props type and acquiring the props from Astro.props

src/components/Title.astro
---
// This is the script
// Define a `Props` type alias or interface.
type Props = {
title: string;
};
// Acquire the props
const {
title = "Meow", // You can even use a default value!
} = Astro.props;
---
<!-- This is the template -->
<h1>{title}</h1>

Components are composable, which means it’s possible to nest them:

src/components/Meow.astro
---
// Let's import the Title component we made above
import Title from "./Title.astro";
---
<!-- We'll get an error here if we don't pass the right props -->
<Title text="Meow" />

There’s a lot more to say about components, but that’s the gist. I highly recommend reading the whole section of the docs on components I linked at the start of this section.

Authoring content

I wanted to keep the content authoring process as lightweight as possible, which is why I decided to use Markdown. In fact, I decided to go one step further and use MDX instead of plain Markdown. MDX is a superset of Markdown which makes it possible to embed framework components (including Astro) inside Markdown files. That’s an important feature to me since I want both the simplicity of Markdown, but also the flexibility of being able to add custom elements to my posts.

To integrate MDX with Astro, all I had to do was run the following command

Terminal
npx astro add mdx

The astro add utility is the preferred way to add integrations to Astro. We’ll use it many more times throughout this article. Note that you may need to install npx with npm install -g npx.

To organize my blog posts, I used Astro’s built-in content collections feature. This allows me to author MDX files and let Astro automatically pick them up and add them as routes (I will explain this shortly). It also allows me to gather a list of all my articles with the getCollection function, which is useful for the blog front page to display a list of all the articles. As a bonus, Astro type-checks and validates the front matter of my MDX files to ensure that each article has exactly the right metadata.

The way content collections work is by creating a src/content folder, and a subfolder with the name of the collection (blog in my case). Inside the content folder, I defined a config.ts validation schema:

src/content/config.ts
import { defineCollection, z } from "astro:content";
const blog = defineCollection({
type: "content",
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
cover: z
.object({
src: image().refine((img) => img.width == 960 && img.height == 480, {
message: "Cover image must be 960 by 480 pixels exactly.",
}),
alt: z.string(),
})
.optional(),
}),
});
export const collections = { blog };

Here I exported a collection named blog. This will ensure that all my content has the right front matter. The config can validate properties, types, and value constraints. For example, in the snippet above I ensured that cover images are optional, but if one is given it must have size and alt attributes. The refine method lets me specify a required image size.

Now we need to tell Astro to generate a route for each item in the collection. Using Astro’s statically-resolved dynamic routing feature, I created src/pages/blog/[slug].astro which will define our routes based on the slug property.

src/pages/blog/[slug].astro
---
import { type CollectionEntry, getCollection } from "astro:content";
import BlogLayout from "../../layouts/BlogLayout.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { slug: post.slug },
props: post,
}));
}
type Props = CollectionEntry<"blog">;
const post = Astro.props;
const { Content } = await post.render();
---
<BlogLayout {...post.data}>
<Content />
</BlogLayout>

The getStaticPaths function is what Astro will use to define the concrete routes to our content. It fetches all the blog posts with getCollection and then sets the route parameters (only the slug in this case).

The markup of this route is written from the perspective of one individual dynamic route from the content collection. In this case, I just extracted its props (corresponding to the front matter we defined previously) and passed them down to a BlogLayout component responsible for the actual layout of a blog post. The actual MDX content is rendered to HTML and passed as a slot.

Now, all I need to do is create a new folder in my blog content collection and put an index.mdx file along with all the assets used in the article inside that folder. Provided the front matter is valid, the content is automatically added to the blog.

For reference, here’s a valid front matter for the schema given earlier:

---
title: "Building a site with Astro and TailwindCSS"
description: "A blog article where I explain how I made this blog using Astro"
pubDate: "Nov 27 2023"
cover:
{
src: "./cover.jpg",
alt: "A picture of a black cat in front of a laptop computer, reading this blog.",
}
---

Optimizing images

One thing that I love about Astro is how easy and seamless it is to optimize image content. I won’t go into details about what kind of optimizations are performed exactly, but the general idea is improving load times and reducing content shift (that’s how much the page moves around as content is loaded).

The image optimization API takes a slightly different form depending on the context. For example, I can use Astro’s Image component directly in the markup:

---
import { Image } from "astro:assets";
import catImage from "../assets/cats/hazel_happy.svg";
---
<Image src={catImage} alt="Happy cat face." />

I can also use getImage directly from a JavaScript context:

---
import background from "../assets/bg.svg";
const optimizedBackground = await getImage({
src: background,
format: "svg",
});
---
<div style={`background-image: url(${optimizedBackground.src});`}>
{/* Div has optimized background image */}
</div>

This is useful anytime you can’t use the Image component, like in this previous example where we pass the image directly as an inline style.

Images referenced in Markdown/MDX files using the standard ![<title>](<path>) syntax are optimized automatically using the same getImage under the hood. If you’re using MDX you can even use the Image component directly.

When Astro builds the project, it reports decent improvements to image sizes:

▶ /_astro/cover.75994f7c_Z3lJ9F.webp (before: 92kB, after: 27kB)
▶ /_astro/cover.3e079873_ZUYFG6.webp (before: 258kB, after: 16kB)

Styles

I fully subscribe to the “automate the boring stuff” and “optimize the happy path” mindset. To me, styling is laborious and boring, and I don’t know much about graphic design. I don’t mind writing CSS, especially with Astro’s scoped styles, but finding sensible styles for everything from spacing to tracking, margins, etc… It can be excruciating if you’re like me.

That’s why I decided to go with TailwindCSS. It offers a fully-featured and flexible toolkit of utility classes. It’s like a box full of useful CSS building blocks. TailwindCSS has a built-in design system, which means it comes with predefined values and increments for styles, but it’s also highly customizable and extendable. This gives me access to standard building blocks I can use to make something both unique and well-designed.

To integrate TailwindCSS with Astro, we again use the astro add utility:

Terminal
npx astro add tailwind

Typography

Another exciting aspect of TailwindCSS is that it can be augmented with a typography plugin. This takes care of styling common textual elements to optimize readability. In my case, it makes it a breeze to keep my content readable at all screen sizes. I want my content to be as accessible and readable as I can, so I just went all in with the TailwindCSS typography stuff. All the blog content uses it.

Using the typography plugin is pretty intuitive. It provides a set of prose classes that apply or modify typography styles.

Here’s how I installed it:

Terminal
npm install -D @tailwindcss/typography

Then, I added it to my TailwindCSS config:

tailwind.config.mjs
/** @type {import('tailwindcss').Config} */
module.exports = {
// ...
plugins: [
require("@tailwindcss/typography"),
// ...
],
};

The typography plugin can be configured in the TailwindCSS config, but it also provides classes you can use to customize the prose styles on a per-element basis.

Syntax highlighting

By default, Astro uses Shikiji to handle code blocks. It works great out of the box, but I wanted some additional features like titles, line highlights, etc. Luckily, the expressive-code integration takes care of this:

Terminal
npx astro add astro-expressive-code

With this, I now have access to additional markup to create beautiful code blocks.

To customize the highlight theme, all I needed to do was download the Textmate files for my themes and import them into my Astro config:

import expressiveCode from "astro-expressive-code";
import ayu_mirage from "./src/assets/ayu-mirage.json";
import ayu_light from "./src/assets/ayu-light.json";
export default defineConfig({
integrations: [
// ...
expressiveCode({
themes: [ayu_mirage, ayu_light],
}),
mdx(),
// ...
],
// ...
});

Here I added one light mode theme and one dark mode theme. expressive-code will automatically pick an adequate one for the user depending on their system preference.

Notice how the expressiveCode integration comes before mdx. The order is significant here.

Fonts

I’m pretty particular about monospaced fonts, so, of course, I’m going to customize the font for my code blocks. I downloaded the fonts to my asset folder (I used Cascadia Code). Then, I made sure the fonts were loaded.

src/components/BlogLayout.astro
<style>
@font-face {
font-family: "CascadiaCode";
src: url("/fonts/CascadiaCode.ttf") format("truetype");
font-style: normal;
}
@font-face {
font-family: "CascadiaCode";
src: url("/fonts/CascadiaCodeItalic.ttf") format("truetype");
font-style: italic;
}
</style>

I put this snippet in the BlogLayout component which is responsible for laying out and styling blog posts.

The only thing remaining was to instruct TailwindCSS to use these fonts for monospaced content. I added a theme extension to my TailwindCSS config:

tailwind.config.mjs
theme: {
extend: {
fontFamiliy: {
mono: ['CascadiaCode', 'monospace'],
},
},
}

I used this for monospace fonts, but you can use the same process to add any kind of font.

The expressive-code integration we added earlier hijacks the styling of code blocks in markdown content, so we’ll also need to configure it:

astro.config.ts
export default defineConfig({
integrations: [
// ...
expressiveCode({
// ...
styleOverrides: {
codeFontFamily: "CascadiaCode",
},
}),
// ...
],
site: "https://double-pendulum.pages.dev/",
});

Dark mode

In my opinion, every site should respect system preferences for light and dark themes. I was adamant about implementing this feature, but I was not sure how to do so cleanly.

My goal was to define a single set of color names that would take different actual colors under light and dark themes. That way, I could style using the same color name and have it work with all themes. The easiest way to make this work was to use the tw-colors TailwindCSS plugin.

Terminal
npm i -D tw-colors

Themes are defined inside the TailwindCSS config using createThemes:

tailwind.config.mjs
import { createThemes } from "tw-colors";
module.exports = {
// ...
plugins: [
createThemes({
lightTheme: {
// ... define colors here
},
darkTheme: {
// ... define colors here
},
}),
],
};

Once the themes have been defined, we can use the colors as normal with our TailwindCSS classes.

To tie this all together, all I had to do was use the dark variant to select the right theme. The dark mode variant is selected based on the user’s system preference. I applied this to the <body> element in my Layout component:

layouts/Layout.astro
<body class="lightTheme dark:darkTheme">
<!-- ... -->
</body>
Hazel being happy
Awesome! Dark themes are so much easier on my eyes.
Edgar being happy

Actually, studies have shown that light themes increase reading comprehension…

Well, whichever you prefer, now you’re covered. Let’s move on.

My blog posts tend to err on the longer side so it’s important to provide users with ways to navigate and refer to the different sections.

Scrolling

Currently, clicking an anchor link immediately “teleports” the user. I’d like the page to scroll smoothly instead, to give the user a sense of where they’re navigating. Let’s fix this:

src/layouts/Layout.astro
<html lang="en">
<html class="scroll-smooth" lang="en">
<!-- ... -->
</html>

Great. There’s another outstanding problem with scrolling though. I’ve opted for a sticky navbar but unfortunately, this messes with anchor navigation since the navbar hides whatever we jumped to! We can fix this using the scroll-margin CSS attribute.

src/layouts/BlogLayout.astro
<article class="... prose-headings:scroll-mt-16">
<!-- ... -->
</article>

This will clear the height of the navbar.

Linkable headings

I’d like to make headings clickable and add a section marker when they are hovered. This allows the user to see the anchor link and save or share it if they so desire. This is achieved through the rehype-slug and rehype-autolink-headings plugins.

Edgar being happy Remark/Rehype

Remark and rehype are collections of tools to manipulate Markdown and HTML syntax trees respectively. This is useful as it allows plugins to parse, remove and add nodes to achieve their desired effect. There is a plethora of plugins based on remark and rehype that we can use to augment our mdx files.

We can install these directly:

Terminal
npm install rehype-slug rehype-autolink-headings

Then we can add them to our astro config. I found rehype-autolink-headings’s settings to be a bit obtuse and hard to grasp, but here’s what I ended up with. Hopefully, this can at least orient you if you’re doing something similar, but bear in mind that yours will probably look pretty different, especially if you’re not using TailwindCSS’s typography plugin.

astro.config.ts
import rehypeSlug from "rehype-slug";
import rehypeAutolinkHeadings from "rehype-autolink-headings";
export default defineConfig({
// ...
markdown: {
rehypePlugins: [
rehypeSlug,
[
rehypeAutolinkHeadings,
{
behavior: "wrap",
properties: {
className: [
"relative flex [font-weight:inherit] [text-decoration:inherit] [color:inherit] group",
],
},
content: {
type: "element",
tagName: "div",
properties: {
className: [
"self-center text-[120%] hidden group-hover:block absolute -left-7 text-primary",
],
},
children: [
{
type: "text",
value: "§",
children: [],
},
],
},
},
],
],
},
// ...
});

Refer to the rehype-autolink-headings docs to learn more.

Be mindful that if you plan to pass TailwindCSS styles through the properties className field, you must add your astro config file to TailwindCSS’s list of content.

Table of contents

To add a table of contents, we’ll use the remark-toc plugin.

Terminal
npm install remark-toc

Then, we can add that to our config, (bearing in mind that rehype and remark plugins are distinct)

astro.config.ts
import remarkToc from "remark-toc"
export default defineConfig({
// ...
markdown: {
remarkPlugins: [remarkToc],
// ...
},
// ...
});

This plugin will look for the first heading named Table of Contents (case-insensitive, you can configure this), and add the table of contents under it.

Sitemap

Astro has integrations that you can use to automatically generate a sitemap and a robots file.

As with all the other integrations, it’s a breeze to set up:

Terminal
npx astro add sitemap
npx astro add astro-robots-txt

With these plugins installed, the required files are generated automatically when I build my project. I also added the sitemap relation to my head element, inside my Layout component:

layouts/Layout.astro
<head>
<!-- ... other stuff ... -->
<link rel="sitemap" href="/sitemap-index.xml" />
</head>

Deployment

I wanted a lightweight site, not only for performance and accessibility but also because static sites are easy to set up and cheap to host. I’ll showcase two platforms I tested to deploy my site: GitHub and Cloudflare.

Before deploying though, the site config property must be set to the URL of the deployed site.

GitHub Pages

GitHub Pages seemed like the easiest way to get started since I host my repo on GitHub, so that’s what I did first. I just created a simple workflow file to deploy the site in CI:

.github/workflows/deploy.yml
name: Deploy
on:
# Deploy on pushes to `main` branch
push:
branches: [main]
# Allow this job to clone the repo and create a page deployment
permissions:
contents: read
pages: write
id-token: write
# Build and deploy
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Build
uses: withastro/action@v1
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v1

Finally, I created a GitHub Pages repo, pushed my code, and we’re d—

Edgar being happy

Remember when you wasted an hour trying to figure out why your site wasn’t deploying, only to realize that you hadn’t set the site’s source in GitHub?

Haha !… Yeah… Thanks for reminding me, Edgar.

Well, don’t be careless like I was: read the docs! I might as well point out that Astro has top-tier documentation.

Anyway, after that hiccup everything worked. Now, whenever I push to the main branch, the site is redeployed automatically.

Cloudflare Pages

GitHub Pages worked just fine, but Cloudflare Pages has additional benefits:

The reason I looked at Cloudflare Pages in the first place is because I noticed GitHub Pages was serving my content with a bad caching policy. This issue has been known for years, but GitHub doesn’t seem interested in changing this.

For this procedure you don’t need workflow or config files. Deploying an Astro project on Cloudflare Pages is fully automated. I just created a new Cloudflare Pages project and followed the instructions. I was able to connect with my repo and select the built-in Astro integration. That’s pretty much as easy as it can get.

Conclusion

I’m pleased with the result! I enjoyed working with the Astro/TailwindCSS/Cloudflare stack. Astro was intuitive to use and, to me, it felt like that’s how web UI was always meant to be made.

I learned a ton along the way, to the point I feel comfortable maintaining and improving the site over time. Hopefully, this article will be useful to someone else considering making a site with a similar stack.

Shout out to Josicat for doing the illustrations and helping me out with some of the styling work. Thank you!