Custom URL formats in Astro

How to construct static URLs with multiple parameters

March 15, 2024

I recently migrated this site from Hugo to Astro. Hugo served me well, but despite years of experience with it, it never felt very intuitive to me. Having the simplicity of Markdown files with all the power of a JS/TS framework made Astro an attractive alternative, so I made the switch and it’s mostly been smooth sailing.

The only real snag I hit was with routing. Because I was already using a /posts/YYYY/MM/slug/ format for post URLs, I needed Astro to do the same thing.

With a brief search, I found this guide that looked like it would do the trick:

Unfortunately after implementing the changes suggested there, an npm run build would lead to Astro recursively generating nested year/month subdirectories and URLs, like /posts/2021/02/2021/02/slug/.

I’m not completely sure why that was happening, but I suspect it had something to do with the slug being modified directly. Maybe there were some changes to the way Astro processes static path parameters since the abovementioned post.

The way I fixed it was to leave the slug unmodified and specify both the relative and absolute paths on each post, allowing post routes to be generated cleanly with no side effects:

src/util/collection.js

import { getCollection } from 'astro:content';

export async function loadAndFormatCollection(name) {
    const posts = await getCollection(name);

    posts.forEach(post => {
        const date = new Date(post.data.pubDate);
        const year = date.getFullYear();
        const month = date.getMonth() + 1;
        const monthZerofilled = (month < 10 ? '0' : '') + month;

        post.relativePath = `${year}/${monthZerofilled}/${post.slug}/`;
        post.absolutePath = `/posts/${post.relativePath}`;
    });

    return posts;
};

src/pages/posts/[…slug].astro

---
import { type CollectionEntry } from 'astro:content';
import Post from '../../layouts/Post.astro';
import { loadAndFormatCollection } from '../../util/collection';

export async function getStaticPaths() {
    const posts = await loadAndFormatCollection('posts');

    return posts
        .map(post => {
            return {
                params: { slug: post.relativePath },
                props: post
            }
        });
};

type Props = CollectionEntry<'posts'>;

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

<Post {...post.data}>
    <Content />
</Post>

Conveniently, the properties also make link generation a little cleaner—instead of doing <a href={`/blog/${post.slug}/`}> (which is how it’s done in the official Astro blog template), you can simply do <a href={post.absolutePath}>. Much better!

Obviously, you can use this approach to generate just about any kind of URL structure you need. In this case I just pulled the year and month from the date specified in frontmatter, but you can use anything to build the parameters.

If you’re interested in seeing the rest of the source for this site, check out the repository:

Thanks for reading!