meme of Astro's getStaticPaths function

Filtered & Paginated Dynamic Routes with Astro

Learn how to implement filtering and pagination using dynamic routes in Astro. This guide expands on Astro's documentation with a detailed, practical example.

Skip straight to the result if you’re just here for the smelly EXE.

Concepts

What does filtered and paginated dynamic routes mean?
It’s likely what you are looking for if you’re building a content focused static site.

Breaking it down:

  • Dynamic Routes: Routes generated at build time based on the content of your site,
    e.g. /blog/this-article, /blog/that-article.
  • Filtered Routes: Routes generated with a filtered set of the content on your site,
    e.g. /blog/tutorials, /blog/articles
  • Paginated Routes: Routes with content spread across multiple pages,
    e.g. /blog/2, /blog/3.

While these pages might display different sets of content, it’s likely the design and layout will be mostly the same. This is where this guide comes in.

The Problem

In contrast to dynamic routes, static routes are generated based on the structure of your site.

Imagine we have a dozen or so markdown articles and we want to generate a page for each, along with a page at /blog to browse avaliable articles. The default Astro blog template provides us with this starting point:

As more content is added, we want to offer users an easier way to navigate articles with result filtering and pagination. So we duplicate the index.astro page into different folders, updating which posts we are fetching in each new static route:

While this works, it’s clear this is not the right approach.

If we were instead to use dynamic routes, a single file could generate all of our unfiltered, filtered, and paginated routes for us, resulting in a much more maintainable codebase. getStaticPaths() has an optional argument paginate that handles pagination for us. Using that, we can replace our static routes with a dynamic page parameter [...page].astro:

Much better. This is what I’ll be going through how to do in this guide.

Implementation

Jump straight to #pagination or #filtering if you’re already familiar with the basics, or if you have a site set up and ready to go. Otherwise I’ll walk through the initial setup.

Getting Started

For this guide I will be using the official Astro blog template as a starting point.

npm create astro@latest # selecting 'Use blog template' when prompted

Like in the example before, our src/pages/ starting point now looks like this:

Navigate to src/content/ and see five markdown files that we’ll be using to generate our dynamic routes. But before we can begin filtering posts we need something to filter by.

Edit config.ts adding a tag key to the blog schema.

src/content/config.ts
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
type: 'content',
// Type-check frontmatter using a schema
schema: z.object({
title: z.string(),
description: z.string(),
tags: z.enum(["article", "tutorial", "portfolio"]);,
// Transform string to Date object
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.string().optional(),
}),
});
export const collections = { blog };

Then let’s make zod happy by adding the new tag field to our markdown files.

src/content/blog/first-post.md
---
title: 'First post'
description: 'Lorem ipsum dolor sit amet'
tag: 'tutorial'
pubDate: 'Jul 08 2022'
heroImage: '/blog-placeholder-3.jpg'
---

Pagination

In order to create a dynamic route for pagination, we need to rename index.astro to [page].astro. If you want the first page of results to drop the page index—i.e. /blog rather than /blog/1—make it a rest param like so [...page].astro.

If you were running the dev server while renaming, you will have seen the following message
[ERROR] [GetStaticPathsRequired] ... in the console.

Let’s go ahead and open [...page.astro] to implement what it’s asking. As we’re going to be using the paginate() function to generate our paginated routes, we’ll need to pass it into getStaticPaths() as it’s an optional argument.

src/pages/blog/[...page].astro
4 collapsed lines
---
import { getCollection } from 'astro:content';
import FormattedDate from '../../../components/FormattedDate.astro';
import BlogLayout from '../../../layouts/BlogLayout.astro';
import type { GetStaticPathsOptions } from 'astro';
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
// by default the template sorts posts from oldest to newest
// I've reversed the order here as a personal preference
const entries = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
return paginate(entries, {
pageSize: 3, // the number of entries per page
});
}
---

You can configure the number of posts per page using the pageSize option. As we only have a few posts, I’m setting it to 3 in order to see what we’re doing for now.

Finally, we just need to update the posts map to use page.data instead. This is where the array of entries we are passing as the first argument to paginate() end up.

src/pages/blog/[...page].astro
10 collapsed lines
---
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
const entries = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
return paginate(entries, {
pageSize: 3,
});
}
const { page } = Astro.props;
---
4 collapsed lines
<BlogLayout>
<section>
<ul>
{
page.data.map((entry) => (
<li>
<a href={`/blog/${entry.slug}/`}>
<img
width={720}
height={360}
src={entry.data.heroImage}
alt=''
/>
<h4 class='title'>{entry.data.title}</h4>
<p class='date'>
<FormattedDate date={entry.data.pubDate} />
</p>
</a>
</li>
))
}
3 collapsed lines
</ul>
</section>
</BlogLayout>

And that’s it. Navigate manually to /blog/2 to see the pagination in action. Of course, you’ll need to add some UI elements to make it possible for users to navigate between pages. Reading the page prop reference gets you there, or otherwise I’ve made a simple UI component to get you started.

Filtering

As with pagination, since we are looking to create a new dynamic route we will create a new dynamic route param. However, this time we’ll do so by moving [...page].astro into a new folder [...filter].astro:

Now we need to update our getStaticPaths() function to return a different set of paginated results for each filter—the tag we added to each post and defined in our schema.

src/pages/blog/[...filter]/[...page].astro
---
export async function getStaticPaths({ paginate }: GetStaticPathsOptions) {
const entries = (await getCollection('blog')).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);
// get tags in use
const tags = [...new Set(entries.flatMap((entry) => entry.data.tag))];
// return a new array
return [
// paginated entries from before
...paginate(entries, {
pageSize: 1,
props: { tags }, // tags are passed as a prop
}),
// paginated entries filtered by tag
...tags.flatMap((tag) => {
return paginate(
entries.filter((entry) => entry.data.tag === tag),
{
params: { filter: tag }, // filter page param is tag `/blog/${tag}`
pageSize: 1,
props: { tags, tag }, // current tag passed as prop as well
}
);
}),
];
}

Since we are using a rest ... parameter in our [...filter]/ page route, leaving the filter param undefined when returning our unfiltered entries generates those pages at the root /blog/.

I’ve adjusted our results per page pageSize: 1 to ensure we generate multiple pages in our filtered routes to test. Let’s also quickly enhance our cards so we can see better see if things are working as expected:

const { page, tags, tag } = Astro.props as {
13 collapsed lines
page: Page<CollectionEntry<'blog'>>;
tags: CollectionEntry<'blog'>['data']['tag'][];
tag: CollectionEntry<'blog'>['data']['tag'];
};
---
<BlogLayout>
<section>
<TagCloud
{tags}
{tag}
/>
<ul>
{
page.data.map((entry) => (
<li>
<a href={`/blog/${entry.slug}/`}>
<img
width={720}
height={360}
src={entry.data.heroImage}
alt=''
/>
<h4 class='title'>{entry.data.title}</h4>
<p>{entry.data.tag}</p>
<p class='date'>
<FormattedDate date={entry.data.pubDate} />
</p>
</a>
</li>
))
}
11 collapsed lines
</ul>
</section>
{
page.lastPage > 1 && (
<Pagination
{page}
{tag}
/>
)
}
</BlogLayout>

Test the filtered routes working by navigating to the url /blog/tutorial. Again, this isn’t a very user friendly way to navigate, so heres a simple tag cloud component to help get you started.

Result

If you’ve followed along you should now have Filtered & Paginated Dynamic Routes working. Otherwise you can check out my example github repo.

page of filtered results

I hope this guide helped with understanding some of the concepts and implementation details behind dynamic routes in Astro.

Reach out with any questions or feedback on Twitter, or by opening an issue on the repo.