
Migration to the Astro Framework #3: Astro
Setting up the Astro web framework for my personal blog and migrating the Hugo Markdown files.
Contents
Overview
Part 3 of my article series describes the configuration of Astro for my blog and the migration of the “old” Hugo articles.
Here are the links to the other articles in this series:
- Part: Basics, GitHub Repository
- Part: Azure Static Web App and Azure DNS
- Part: Astro
- Part: Tailwind CSS
Prepare and install Astro
I won’t describe the installation of Astro in detail here, since the official documentation (Install Astro) is very good and the steps are easy to follow.
The first Astro page
My first step after the installation was to create a new, very simple page (page) /pages/imprint.astro: the imprint (legal notice). The imprint consists only of static text without any additional functionality and was a good opportunity to explore the basics of Astro layouts.
An .astro file always starts with its own “component script (JavaScript)” block, enclosed in ---. The imprint page initially consists only of an import directive that references a component from the layouts directory and is then used with <Layout>...</Layout>.
The layout parameters are simply passed in the attributes of the <Layout> tag, for example title="Imprint"
---
import Layout from "../layouts/BaseLayout.astro";
---
<Layout title="Imprint" description="The legal fine print: imprint and privacy policy">
... HTML content ...
</Layout>
The result is rather unspectacular at first and simply renders the HTML content of imprint.astro at /imprint using the referenced layout. So let’s take a closer look at this layout.
A simple layout
The so-called “layouts” are Astro components and contain reusable templates for HTML UI elements such as headers, footers or navigation bars.
My original BaseLayout initially imports three other components:
BaseHeadcontains metadata for the HTML<head>, such as links to the fonts, the favicon and the page title, which is passed as the attributetitle.- The
Headercomponent contains the navigation bar and the social media links at the top of the page. - The
Footerreally only consists of a link to the imprint and the social media links again.
The actual content, which is passed in by a (page) such as the imprint.astro defined above, can be inserted anywhere with the <slot /> tag.
Here is the complete example:
---
import BaseHead from "../components/BaseHead.astro";
import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro";
const { title, description } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<BaseHead title={title} description={description} />
</head>
<body>
<Header />
<section>
<slot />
</section>
<Footer />
</body>
</html>
In the next step, let’s look at one of the most important building blocks of Astro:
Components
The components are flexible “building blocks” that can be nested arbitrarily by pages and other components. Components can only contain HTML code, but no client-side runtime!
In the previous example I used the Footer component. Let’s look at it in detail:
---
import SocialLinks from "./SocialLinks.astro";
const today = new Date();
---
<footer class="...">
©{today.getFullYear()} Thomas Hartl
| made with ♥️ and <a href="https://astro.build/" target="_blank">Astro</a>
| <a href="/imprint">Imprint and Privacy Policy</a>
<SocialLinks />
</footer>
In the component script (between the ---),
- another component
SocialLinksis imported, which renders the social media links, and - a constant
todayis defined, which contains the current date.
Then a simple <footer> is defined in the HTML code. JavaScript code can be executed inside a {} block, for example to output variables. In this example, {today.getFullYear()} renders the current year.
With these building blocks you can already build a basic, static website. But what I actually wanted was to use my blog, consisting of Markdown content, in Astro. For that we need more building blocks:
Markdown
The blog posts of the previous Hugo blog are already in Markdown format. I can therefore copy them directly into the /content directory and keep the existing blog structure with subfolders for year and month. The directories now look roughly like this:
content/
+ blog/
+ 2020/
| + 08/
| - post1.md
| - post2.md
+ 2021/
+ 03/
- post3.md
[...]
The Markdown documents consist of a header enclosed in --- (“code fence”) and the Markdown content itself. The header defines the metadata of the post, just like in the previous Hugo blog:
titleas the post title.descriptionas a short description that is shown, for example, in the post overview.pubDatecontains the (original) publication date of a post.updatedDate, on the other hand, is an optional update date.draftcan be used to mark a post as a “draft”. Drafts should not be rendered on the site - more on that later.- The
heroImagecontains a reference to the post image. tagsis a list of keywords.
In the next step we have to teach Astro to load the Markdown documents and assign the metadata correctly.
Content Collections
With content collections we can use structured content - in our example files, or Markdown documents - in Astro.
Define a content collection
Before we can use a content collection, we first have to define it in src/content.config.ts. In my case I want to load all Markdown documents (_.md and _.mdx) in my blog directory /src/content/blog. For that I use the predefined glob() loader, which searches a directory (base) for files matching a certain pattern (pattern) and loads them.
In addition, every content collection needs a schema, which is validated with Zod. The schema of my blog collection is the same one I already used in the Hugo blog, as described above, with a few peculiarities:
updatedDateis defined asoptional.draftis also anoptionalboolean value.tagsis a list that may be empty [], but notnull.
This is what the definition of the blog collection including the schema looks like:
import { defineCollection, z } from 'astro:content';
import { glob, file } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/[^_]*.{md,mdx}' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
draft: z.boolean().optional(),
heroImage: z.string().optional(),
tags: z.array(z.string()),
}),
});
Query the blog collection
Now that we have defined a content collection for our Markdown blog posts, we can query it with the following Astro functions:
getCollection()returns all entries of a collection as an array. Exactly the right thing for the blog post overview page.getEntry()returns a specific entry of the collection.
In the simplest case, we load the entire collection:
---
import { getCollection } from "astro:content";
const posts = await getCollection("blog");
---
It gets a little more complicated if we only want to load posts that are not marked as draft. For that we use a filter callback:
---
import { getCollection } from "astro:content";
const posts = await getCollection("blog", ({ data }) => { return data.draft !== true; })
---
Since getCollection() returns a JavaScript array, we can sort the posts by any attribute with the Array.sort() function and (optionally) a comparison function. Here, for example, by updatedDate or, if that doesn’t exist, by pubDate:
---
import { getCollection } from "astro:content";
const posts = (
await getCollection("blog", ({ data }) => { return data.draft !== true; })
).sort(
(a, b) =>
(b.data.updatedDate?.valueOf() ?? b.data.pubDate.valueOf()) -
(a.data.updatedDate?.valueOf() ?? a.data.pubDate.valueOf())
);
---
With the “blog” content collection and the two functions getCollection() and getEntry()
Render blog posts from Markdown
Blog overview page
First we build an overview page for the blog posts at /pages/blog/index.astro. In it we load our blog posts into the variable posts with getCollection() as described above. Then we use the map() function to pass the metadata of each post (title, description, date, tags, …) on to a component of our own, <Card>. The <Card> component renders a nice block with the title and description of the post from it:
<Layout pageTitle={pageTitle}>
<div class="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<Card
href={`/blog/${post.id}/`}
title={post.data.title}
description={post.data.description}
date={post.data.updatedDate || post.data.pubDate}
tags={post.data.tags}
imagePath={`/src/images/hero/${post.data.heroImage}.jpg`}
/>
))}
</div>
</Layout>
Each <Card> block is rendered with a link (href={`/blog/${post.id}/`}) to the post. We still have to create this blog post page, though:
Blog post page
To render a route for every post in our blog content collection, we have to work with getStaticPaths() & render() and create a file [...slug].astro under /pages/blog. The slug is replaced by the id of the post when rendering.
The process is described in detail in the documentation (Building for static output (default)). Here is the example for our blog collection:
---
import { type CollectionEntry, getCollection, render } from "astro:content";
import BlogPost from "../../layouts/BlogPost.astro";
export async function getStaticPaths() {
const posts = await getCollection("blog");
return posts.map((post) => ({
params: { slug: post.id },
props: post,
}));
}
type Props = CollectionEntry<"blog">;
const post = Astro.props;
const { Content } = await render(post);
---
<BlogPost {...post.data}>
<Content />
</BlogPost>
With that, the blog is functionally ready to use. In the next article we’ll take care of the design:
Next article
Continue with Migration to the Astro Framework Part 4 with Tailwind CSS.