Setting up a blog with Astro - Part 1: The static site engine

Getting started with Astro, and building up the site. Published:

Motivation

I wanted my own little corner of the internet. A static site removes much of the maintenance concerns, while a modern generator offers plenty to play with.

Why Astro?

There are a lot of great options out here, to name a few:

I suppose any will do as long as you like the template engine and language used to extend it.

I already have plenty of experience with TypeScript and Angular, plus a bit with React/Vue, so Astro’s component model made a lot of sense. Plus I’ll be writing some client-side JS anyway.

Getting started

Installation / pnpm

We’re civilised people around here so we use pnpm.

As far as setting up the project goes, everything you need can be found in the Astro docs.

Terminal window
pnpm create astro@latest

As far as CLI helpers go, Houston was pretty straightforward to use. Picking the blog starter template provides a decent starting point.

Picking a starting theme

This is entirely optional - I tried a few from Astro’s gallery, but was never really satisfied with them. If you’re going to customize, I’d recommend sticking to something basic.

Astro isn’t complicated. Many features are available through plugins, and writing your own components is easy enough. Picking a feature-packed theme if you intend on making it your own essentially burdens you with whatever “debt” has accumulated in the starter:

  • Major versions may have changed (ex: Taiwind4 vs Tailwind3, if that’s your thing)
  • Integrations may have been added / deprecated
  • Some patterns / workarounds may have been made unnecessary

By anchoring yourself to a heavy, existing starter, you essentially deprive yourself of a learning opportunity, and take on unnecessary work to undo-redo some of the stuff.

A better way to use those is to look at the live demos for inspiration about which features you like, and trying to re-implement them with a modern standard.

I went with UINUX GitHub / Demo.

I really liked the ideas (blog posts) used to showcase the starter. While writing this I realised they’ve updated the system to handle tags - not something I need right now, but I’ll definitely have a peek later on.

Poking around

Before going further, it may be a good idea to pad the site with placeholder content. One good use of AI here is to generate some slop articles; it looks better than Lorem Ipsum, and the models can produce markdown format, which makes it easy to feed into the parser and focus on the design work.

Just make sure you don’t inadvertently publish them.

Humble beginnings

Let’s start with a simple change: I’d like to keep only the latest posts on the home page.

src/pages/index.astro
const posts = (await getCollection("posts"))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0, 5);

Slicing the posts array is enough to limit what is displayed on the homepage.

Of course, pagination is not handled yet, but that will come later.

Fixing some styles

It seemed like the search results weren’t styled properly.

Default search results

Turns out this happens because Astro is too good at what it does: Scoped styles are tree-shaken at build time, and scoped to the elements that exist on the page.

a.search-input-wrap[data-astro-cid-rwvr7nxs] {
margin-top: var(--space-lg);
margin-bottom: var(--space-xl);
}
#search-input[data-astro-cid-rwvr7nxs] {
}

Because there’s no “search” at build time, there are no results, and the styles are lost. The workaround for that is to make them global by one of two ways:

  • Moving them over to global.css
  • Adding a :global() directive around the css selector

I picked the former, but that would soon not matter.

Refactoring: ArticleCard

The problem

While working on the search page, I noticed that the code for the displaying the posts was almost a 1:1 copy of the index page. There were subtle differences in the styling but those were also mostly the same rules under different names.

Extracting a new component while giving it a fresh coat of paint sounds a bit more complex than fixing styles or slicing an array, but it’s also the core of the engine, so it can’t be that hard.

Enter the card component

src/components/ArticleCard.astro
---
import type { CollectionEntry } from "astro:content";
interface Props {
post: CollectionEntry<"posts">;
}
const { post } = Astro.props;
const formattedDate = post.data.date.toLocaleDateString("en-UK", {
year: "numeric",
month: "numeric",
day: "numeric",
});
---
<article class="post-item" id={`article-${post.id}`}>
<a href={`/posts/${post.id}`}>
<h3>{post.data.title}</h3>
<p>{post.data.description}</p>
<p class="article-meta">
<time datetime={post.data.date.toISOString()}>{formattedDate}</time>
</p>
</a>
</article>
<!-- Scoped styles omitted for brevity -->

That is quite a big change. I move away from the ul / li to instead have one article tag per… well, article.

Another change is that I now have an h3 tag as part of the card’s DOM. This is intentional, as I restructured the index to include a <h2>Recent articles</h2>.

Tag hierarchy being preserved, I can move on to the second page which used the ul / li pattern.

Creating an “Archive” page

Having introduced a cap to the number of posts to the home page, I need a way to access other entries - preferably in a format that is easier to navigate than an RSS feed.

I wrote an archive page sorting the posts from newest to oldest, grouping them by year. I threw in a <h2>Year</h2> tag for each - and voilà!

src/pages/archives.astro
---
import ArticleCard from "../components/ArticleCard.astro";
import Layout from "../components/Layout.astro";
import { getCollection, type CollectionEntry } from "astro:content";
import { SITE_DESCRIPTION, SITE_NAME } from "../site.config";
type Post = CollectionEntry<"posts">;
const posts: Post[] = [...(await getCollection("posts"))].sort(
(a: Post, b: Post) => b.data.date.valueOf() - a.data.date.valueOf(),
);
const postsByYear: Record<string, Post[]> = {};
posts.forEach((p: Post) => {
const year: number = p.data.date.getUTCFullYear();
if (!postsByYear[year]) {
postsByYear[year] = [];
}
postsByYear[year].push(p);
});
const years = Object.keys(postsByYear).sort((a, b) => +b - +a);
---
<Layout title="AI prompts You - Archives">
<section class="home">
<header class="home-header">
<h1>Archives</h1>
<p>Past writings, in antichronological order.</p>
<div class="search-input-wrap">
<label for="search-input">Search the archives</label>
<input
type="text"
id="search-input"
autocomplete="off"
autofocus
/>
<p id="search-hits"></p>
</div>
</header>
<div id="article-list">
{
years.map((year) => {
return (
<section>
<h2>
<span>{year}</span>
<span> &darr;</span>
</h2>
{postsByYear[year].map((post) => (
<ArticleCard post={post} />
))}
</section>
);
})
}
</div>
</section>
</Layout>

I also used this opportunity to merge it with the existing search page: the feature didn’t need much work, and I figured it would make sense to have all the digging done in the same place.

Here’s how it looks now:

Capture of the reworked search
page

Not too shabby!

For the CSS part, here’s a relevant snipped that gives the blocks a nice animation when the search debounce timer expires:

src/components/ArticleCard
/* These must be scoped to the ArticleCard */
article {
max-height: 12rem;
transition: all 0.25s ease-in-out;
visibility: visible;
overflow: hidden;
}
article.hidden {
visibility: hidden;
max-height: 0px;
}

Adding previous / next buttons

Another small feature, another component:

src/components/PreviousNext.astro
---
import type { CollectionEntry } from "astro:content";
interface Props {
previous?: CollectionEntry<"posts">;
next?: CollectionEntry<"posts">;
}
const { previous, next } = Astro.props;
---
{
(previous || next) && (
<aside>
<h2>See also</h2>
<nav class="prev-next">
{next?.id && (
<a href={`/posts/${next.data.slug}`}>
<span>{next.data.title}</span>
<span class="icon">&#129050;</span>
</a>
)}
{previous?.id && (
<a href={`/posts/${previous.data.slug}`}>
<span>&#129048;</span>
<span>{previous.data.title}</span>
</a>
)}
</nav>
</aside>
)
}

If the current article has either (or both) a previous or a next article, then the relevant buttons appear. But how do we compute this?

src/pages/posts/[...slug].astro
---
import { getCollection, render } from "astro:content";
import type { CollectionEntry } from "astro:content";
import PreviousNext from "../../components/PreviousNext.astro";
const post = Astro.props;
const { Content } = await render(post);
type Post = CollectionEntry<"posts">;
// Chronological order
const posts: Post[] = [...(await getCollection("posts"))].sort(
(a: Post, b: Post) => a.data.date.valueOf() - b.data.date.valueOf(),
);
function previousNext(id: string) {
const idx = posts.findIndex((post) => post.id === id);
let previous = undefined;
let next = undefined;
if (idx - 1 >= 0) {
previous = posts[idx - 1];
}
if (idx + 1 < posts.length) {
next = posts[idx + 1];
}
return { previous, next };
}
const { previous, next } = previousNext(post.id);
---
<Layout
title={`${post.data.title} — AI prompts You`}
description={post.data.description}
ogType="article"
>
<!-- snip -->
<PreviousNext previous={previous} next={next} />
</Layout>

By sorting the posts in chronological order, finding the index of the current post (which must exist, by definition), we can determine whether there are previous / next articles. We then pass those to our new component, and we’re done!

Lessons learned so far

Scoped styles

Scoped styles are useful but I’d rather not overuse them - any duplicated directive I would rather make global or extract to a component.

I have seen quite a few templates where there’s some confusion as to which rule will apply: because of the way Astro scopes the styles, a cascading directive from global.css which appears to be more specific than a scoped one may be ignored.

Search normalization

The search index is built at compile time, and served as a JSON file.

Here are a few improvements I made to it:

src/pages/search-index.json.ts
body: (post.body || "")
.replace(/---[\s\S]*?---/, "")
.replace(/[#*`\[\]()>_~|\\]/g, "")
.replace(/\n+/g, " ")
.replace(/\s+/g, " ")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.trim()
.toLowerCase(),

What normalize does is split diacritics into two characters. The following regex, courtesy of Stack Overflow, removes those extra characters.

By doing the same on both the search query and the body, accents will not trip me up.

Of course, this would probably not even be an issue if used PageFind, but that’s for another day.

Astro’s built in collection

Disclaimer: I may have hallucinated here.

When building the previous / next article buttons, it felt as if the Post collection was sometimes backwards. Which makes sense, if you assume that Astro builds it once and always returns the same array, given that JS’ sort method works “in-place”.

I worked around that by using the spread operator [...articles] to clone it beforehand. This might have been entirely unnecessary - I haven’t had the chance to investigate yet.

Client Router and View transitions

If using the <ClientRouter /> for the view transitions, make sure to hard refresh / restart the server from time to time, as it can cause caching issues in dev mode.

What’s next?

In the next post, I’ll go over how I set up the VPS and the CI/CD pipeline that automates the deployment.