Skip to content
IT // Adventures

Migration to the Astro Framework #3: Astro

Setting up the Astro web framework for my personal blog and migrating the Hugo Markdown files.

Updated 24 January 20256 min read

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:

  1. Part: Basics, GitHub Repository
  2. Part: Azure Static Web App and Azure DNS
  3. Part: Astro
  4. 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:

  • BaseHead contains metadata for the HTML <head>, such as links to the fonts, the favicon and the page title, which is passed as the attribute title.
  • The Header component contains the navigation bar and the social media links at the top of the page.
  • The Footer really 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="...">
  &copy;{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 SocialLinks is imported, which renders the social media links, and
  • a constant today is 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:

  • title as the post title.
  • description as a short description that is shown, for example, in the post overview.
  • pubDate contains the (original) publication date of a post.
  • updatedDate, on the other hand, is an optional update date.
  • draft can be used to mark a post as a “draft”. Drafts should not be rendered on the site - more on that later.
  • The heroImage contains a reference to the post image.
  • tags is 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:

  • updatedDate is defined as optional.
  • draft is also an optional boolean value.
  • tags is a list that may be empty [], but not null.

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.