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:
- Lightweight. No Javascript shipped to the user.
- 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.
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.
With the previous points in mind, Astro seemed like a good match. It fulfills my two technical requirements:
- Astro focuses on static content and ships no JavaScript by default.
- 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:
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
public
which contains raw public assets.src/
which contains all the sources Astro might need to process. This includes optimizable assets like images and style sheets.src/pages
which contains the pages served by the site.src/content
which contains content collections (we’ll get back to that one soon)
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:
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:
Components can receive props by defining a Props
type and acquiring the props from Astro.props
Components are composable, which means it’s possible to nest them:
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
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:
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.
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:
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:
I can also use getImage
directly from a JavaScript context:
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:
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:
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:
Then, I added it to my TailwindCSS config:
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:
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:
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.
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:
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:
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.
Themes are defined inside the TailwindCSS config using createThemes
:
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:
Actually, studies have shown that light themes increase reading comprehension…
Well, whichever you prefer, now you’re covered. Let’s move on.
Navigation in blog posts
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:
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.
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.
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:
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.
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.
Then, we can add that to our config, (bearing in mind that rehype
and remark
plugins are distinct)
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:
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:
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:
Finally, I created a GitHub Pages repo, pushed my code, and we’re d—
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:
- It has a great dashboard for analytics, even with the free tier.
- It can be used for static or server-side rendered content (SSR). This could be useful if I ever decide to integrate some SSR content.
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!