Eben Gilkenson

Dynamic Routing with Astro

For a blog that I post to approximately once every seven months, this configuration has always been overkill. Instead of just tagging posts, I keep them all organized in top-level categories (currently code, food, and moto), with related posts in each category collected into “series”.

I don’t know if this really makes any sense, but it is at least a very specific structure and posed an interesting challenge at the time. Now that I’ve committed to it, I feel like it’s a requirement to keep all the URLs the same as I ineveitably switch platforms every couple of years.

Just keep in mind that this isn’t a guide to how building a blog should be done with Astro. It’s just something that can be done and likely isn’t going to show up among the usual tutorials.

The Way Things Used To Be

I am migrating the site from Gatsby, which didn’t support any kind of file-based routing for Markdown files when I set this up. So the posts lived in their own posts directory, outside of the root pages directory, and were exposed via Gatbsy’s GraphQL API using the Gatsby filesystem source plugin.

The directory structure expressed which category or series a post beloged to.

├─ code/
│  ├─ cloudflare-workers/
│  │  ├─ _series.md
│  │  ├─ cloudflare-workers-contact-form.md
│  ├─ learning-gatsby/
│  │  ├─ _series.md
│  │  ├─ creating-taxonomies.md
│  │  ├─ css-modules-with-postcss.md
│  │  ├─ gatsby-and-github-actions.md
│  │  ├─ hooking-up-wordpress.md
│  │  ├─ programmatic-featured-images.md
│  ├─ prevent-orphaned-title-word.md
│  ├─ wordpress-custom-taxonomy-homepage.md
├─ food/
│  ├─ baking-steel-pizza.md
├─ moto/
│  ├─ 2020-plans.md

Generating static pages from Markdown with Gatsby begins with a GraphQL query, collecting content from the Markdown files in the specified source directories. From the query response, some unique values are used to determine the URL for each page that will be created using the createPages function. As I wrote about at the time, I ended up rolling my own file-based routing, taking the file path available in the GraphQL response and using that to generate the slug.

Rendering Posts In Astro Just Works

For the individual posts, Astro’s static routing will do the job nicely. Since I had already mirrored the directory stucture to the URLs in Gatsby, I can just move everything from the posts directory into the pages directory with the structure intact. Now all of my Markdown files will be turned into static pages at the same URL as they were on the old site.

No Default Layouts

The only disappointment so far was that I had to manually specify the layout file in the frontmatter of each post. Eleventy’s cascading data files have spoiled me and I just expect to be able to throw a JSON file in a directory and have those defaults apply to all its children.

If there’s a way to do this now, someone please let me know!

Generating Category Indexes

For lists of posts under each category or series, we will need to have some dynamic routing. This is where things get interesting.

After moving the Markdown files to the pages directory, I also created a new [category] directory that contains index.astro and [series].astro files.

├─ [category]/
│  ├─ [series].astro
│  ├─ index.astro
│  ...

If these bracketed file and directory names look unfamilar, they are how a dynamic route is specified in an otherwise file-based routing system. You’ll see the same convention in other frameworks, notably NextJS.

In this case, we are capturing URLs that match a /[category] (via that index.astro file) or /[category]/[series] pattern.

On a server-based site, these might be converted into a query that returns either matching content or a 404. When using dynamic routes for a static site, we have to tell the site generator what pages to build ahead of time. This is done with a getStaticPaths function that is exported from the dynamic route file. Again, very much like NextJS.

In Astro, the getStaticPaths function needs to return an array of objects, each containing params and props objects. The params object matches values to the dynamic parameters in the path. In this case, that could be [category] or [series]. The props object passes information into the pages created at that location.

// if you were to do this manually...
return {
    params: {
        category: 'code',
    props: {
        posts: [
            { title: 'Code Post One', content: 'The content for post one.' },
            { title: 'Code Post Two', content: 'The content for post two.' }

Category Listing Pages

The /[category]/index.astro file will be used to render pages at the /code, /food and /moto endpoints. The files it generates will be /code/index.html and so on. You can see how the code category name slots into that [category] placeholder in the directory structure. That’s how dynamic routes work.

To figure out what needs to be rendered at what path, we need to gather our posts using the built-in Astro.glob function.

/* src/pages/[category]/index.astro */

export async function getStaticPaths() {
    const posts = await Astro.glob("../**/*.{md,mdx}");

// ...

So here we are going up a level and then grabbing all of the Markdown (and MDX) files inside the pages directory. This returns an array that we can iterate over, containing the content of the files and a bunch of other useful data.

No Dynamic Globs

A frustrating aspect of Astro.glob is that it cannot accept dynamic strings as arguments. This is a limitation of Vite’s import.meta.glob upon which it is based.

There are situations where I would like to grab just specific posts based on some criteria provided as dynamic values. If I were accessing a remote API with fetch() or using Gatsby’s GraphQL API, this would be easy. Using Astro.glob, however, the only option is to gather ALL files and dig through them to determine what is needed. That’s not a big deal on a little site like this, but it still feels wasteful and clunky.

So now we have an array of all of the posts. The next thing we need to do is determine the parameters to be provided to getStaticPaths. In this case, that means figuring out what the categories are and then passing all the posts under that category as props.

Determining the Categories

Markdown files in Astro come with a handful of useful props. One of these is a url string which represents the relative path at which the file will be created in the static site output. A Markdown file at src/pages/category/post.md would have a url value of /category/post. This is great for linking to Markdown-based pages, but we can also use it to examine the directory heirarchy and assign a category based on that.

export async function getStaticPaths() {
    const posts = await Astro.glob("../**/*.{md,mdx}");

    const categories = [...new Set(posts.map((post) => post.url.split("/")[1]))];

// ...

Since we know the directory structure, we can take in the URL for each post in the glob and figure out its category “slug”. Because the url begins with a ”/”, there will be an empty item at the 0 index when we split the string. The highest directory in the heirarchy is our category and it will be at index 1. Creating a new Set of all the category slugs and spreading that into an array gives us the unique category names from all of the posts.

Gathering Items for Category Pages

So now that we have the categories, the next step is to collect the data for what is displayed at each path. This is where my “series” setup makes things complicated. In addition to each post immediately inside the category directory, I want to have a stub for each series, with some information about the series and its latest post. It will appear in the chronological list according to the date of the most recent post.

For each category, we need to collect only the posts in its immedate and nested directories.

// ...
    const paths = categories.map((category) => {
    	const category_posts = posts.filter(
            (post) => post.url.split("/")[1] === category
// ...

Now that we have those, we can loop through each series (if any) within that category and create a new object that mimics a post, but contains additional information about the series.

// ...
const series_data = category_posts.reduce((acc, cur) => {
    if (! cur.file.endsWith("_series.md")) return acc;

    const series_slug = cur.url.split("/")[2];
    const series_posts = category_posts
        .filter((post) => {
            const heirarchy = post.url.split("/")
            return heirarchy[2] === series_slug &&
        .sort((a, b) => {
            return a.frontmatter.pubDate > b.frontmatter.pubDate ? 1 : -1;

    if (series_posts.length === 0) return acc;

    const latest_post = series_posts[series_posts.length - 1];

    const series_obj = {
        frontmatter: {
            title: cur.frontmatter.title,
            description: cur.frontmatter.description,
            date: latest_post.frontmatter.pubDate,
        series: true,
        url: cur.url.replace("/_series", ""),
        series_count: series_posts.length,

    return acc;
}, []);
// ...

This is a bit much to take in all at once, but it shouldn’t be too hard to follow what is happening.

First we check to see if the current Markdown file is a series overview. If not, we return the accumulator and move on.

Then we get the series slug from the url property in the same way we got the category slug. We will use that to filter the post list we created previously, selecting only those that are in the current series and sorting them by date. We’ll also filter out any posts beginning with an underscore, so we don’t include the series overview.

If there are no posts in the series, we return the accumulator. If there are posts, we grab the most recent one to use in the series’ entry in the post list.

Finally we generate a new object that closely resembles a regular post object, but has additional details about the series, as well as the entire latest post.

// ...
const post_list = [
        ...category_posts.filter((post) => post.url.split("/").length < 4),
        ...series_data, // add the series overviews
    ].sort((a, b) => {
        return a.frontmatter.pubDate < b.frontmatter.pubDate ? 1 : -1;
// ...

Then we create a new array from the top level category posts and the series overview objects and sort those by date. Now the series overviews will appear in the listing according to the the date of the most recent post.

Feeding Everything to Astro

export async function getStaticPaths() {
// ...
    const paths = categories.map((category) => {
    // ...

        return {
            params: {
            props: {
                posts: post_list,
    return paths;

Finally, we have the information to return from getStaticPaths. The params, which specify the values that match the dynamic route parameters. In this case, we just have the single value for tha category name. Then we have the props which get passed into the template for each page created. This will be the post list and the category slug.

Now Astro will generate a category page for each subdirectory under pages. Each series item includes the latest post and the number of posts in the series, so we can display that information on the category page.

screenshot of post listing

I won’t get into the actual templating, since it is pretty straightforward JSX. The challenge was getting all the data together to build the list.

Moving Forward

I created all this and started writing this post back in September 2022. Since then, Astro 2.0 has been released and introduced Content Collections. It appears to be a neat way to create a kind of file-based CMS with a validated schema.

I’m not sure how many problems Collections would solve for my purposes, but it provides a tidier way to organize content, for sure. If I ever have enough posts to feel like I need to be more organized, I’ll definitely give Collections a try.

For now, I’m happy with the way things are. I’ve managed to move through three different static site generators and keep my URL structure intact.

Now it’s time to work on taking advantage of the new features in Astro and finding something else to write about.

← Learning Astro