Creating a Jekyll-style blog post URL in Astro

It's not obvious from the examples, but you can emulate Jekyll-style blog post URLs including years and months in Astro.

In converting my Jekyll blog to Astro, one of the trickiest parts was ensuring that all of the URLs remained the same. My blog has been around for almost 20 years and it has been converted from different frameworks multiple times, ultimately resulting in some different URL formats. The one I used most recently, however, is the familiar /blog/year/month/title format. In this post, I’ll describe how to create this URL format in Astro.

Step 1: Create the post page

Assuming your blog templates live in src/pages/blog, create src/pages/blog/[...slug].astro. The square brackets around ...slug are intentional and let Astro know that this page has dynamic routes. In this case, you will be replacing ..slug with the URL path to your post. The ... indicates that the value of slug may contain slashes. Astro knows how to replace slug when you define a getStaticPaths() method.

Step 2: Export getStaticPaths()

The getStaticPaths() function is called by Astro to determine how to render dynamic routes. The function must return an array of objects that each contain at least a params key that indicates how to fill in the dynamic portions of the filename and optionally a props key that contains data to make available in Astro.props when the page renders.

To create the appropriate URL for each blog post, you’ll need to export a slug for each post in your collection. Here is the code to do that:

import { getCollection } from 'astro:content';

export async function getStaticPaths() {

	const posts = await getCollection('blog');

    return posts
		.map(post => {

            const date = post.data.pubDate;
            const year = post.getFullYear();
            const month = (post.getMonth() + 1).padStart(2, "0");

            return {
                params: {
                    slug: `${year}/${month}/${post.slug}`
                },
                props: {
                    post
                }
            }
        });
};

The first step in this code is to get all of the blog posts in the blog collection. Each post is then inspected to construct the correct URL format.

If you’re using the recommended setup for Astro, you’ll have a post.data.pubDate property that contains the post’s publication date (the name of the property is configurable — the only important thing is that it’s a date object.) For convenience, the year and month are assigned to variables. The year uses getFullYear() to ensure four digits; the month is 0-based so one is added to get the calendar year and then padStart(2 "0") is called to ensure that there are always at least two digits (i.e., 7 becomes 07).

For each post, the code returns an object containing a params key that contains the slug property with the URL path (it’s important that the URL path does not end with a slash, as this will cause the page to not be rendered) and a props key that contains the post itself.

Step 3: Access the params and props to render

In that same src/pages/blog/[...slug].astro file, after getStaticPaths(), you can then start with the code to render each page. First, you’ll want to gather the information about the page being rendered from Astro.params and Astro.props, like this:

const post = Astro.props;
const { Content } = await post.render();

Now you have the year and the posts from that year, so all you need to do is render out post. Here’s an example:

<main>
    <h1>{post.data.title}</h1>
    <Content />
</main>

Bonus step: Refactor for easier reuse

While the code above works well as an example, in reality you don’t want your URLs to be constructed only within src/pages/blog/[...slug].astro because you’ll also want to reference those URLs elsewhere (i.e., on your blog index page). Rather than copying that logic everywhere, it’s useful to create a helper function that will format the URLs for you, such as this:

import { getCollection } from 'astro:content';

export async function loadAndFormatCollection(name) {

	const posts = await getCollection(name);

    posts.forEach(post => {

        const date = post.data.pubDate;
        const year = post.getFullYear();
        const month = (post.getMonth() + 1).padStart(2, "0");

        post.slug = `${year}/${month}/${post.slug}`;
    });

    return posts;
};

Then in src/pages/blog/[...slug].astro you can simplify the code to:

import { loadAndFormatCollection } from '../../lib/util.js';

export async function getStaticPaths() {

	const posts = await loadAndFormatCollection('blog');

    return posts
		.map(post => {

            return {
                params: {
                    slug: post.slug
                },
                props: {
                    post
                }
            }
        });
};

Any time you would previously use the built-in getCollection(), you should now use the custom loadAndFormatCollection() to ensure that post.slug is the correct value.

Jekyll allows you to override the default URL for any given post by specifying a permalink key in the frontmatter, such as:

---
title: "Hello world!"
permalink: "/blog/2013/12/my-special-post/"
---

Hello world content!

The first thing to notice about the permalink key is that it’s an absolute URL that both begins and ends with a slash. It also begins with “blog”, which is most likely the name of your collection. So, if you want to acknowledge the permalink key as overriding the default blog post URL, you’ll need to take those into account and update the loadAndFormatCollection() function like this:

import { getCollection } from 'astro:content';

export async function loadAndFormatCollection(name) {

	const posts = await getCollection(name);

    posts.forEach(post => {

        const permalink = post.data.permalink;

        if (permalink) {

            const urlParts = permalink.split("/");
            urlParts.shift();       // remove first empty space
            urlParts.shift();       // remove "blog"

            if (permalink.endsWith("/")) {
                urlParts.pop();     // remove last empty space
            }

            post.slug = urlParts.join("/");

            return;
        }

        const date = post.data.pubDate;
        const year = post.getFullYear();
        const month = (post.getMonth() + 1).padStart(2, "0");

        post.slug = `${year}/${month}/${post.slug}`;
    });

    return posts;
};

Now, if there’s a permalink in the post’s frontmatter, that will take priority over the default URL format.

(Note: If you are trying to convert a Jekyll blog to Astro and don’t want to go through all of this manual stuff, check out my astro-jekyll project, which does all of this for you.)

Conclusion

This is another instance where Jekyll includes some functionality by default that Astro makes you jump through a few hoops to implement, but ultimately isn’t all that much additional work. Thanks to the slug property on collection items, it’s fairly straightforward to modify the URLs for your blog posts while not moving too far away from the Astro way of doing things. This is, after all, the strength of Astro: it is completely flexible and you can make it do whatever you want, it just may take a little more work than you’re used to coming from Jekyll.

Understanding JavaScript Promises E-book Cover

Demystify JavaScript promises with the e-book that explains not just concepts, but also real-world uses of promises.

Download the Free E-book!

The community edition of Understanding JavaScript Promises is a free download that arrives in minutes.