Steal This Blog! Part 2: Filtering Collections by Tags, Authors, and More

Creating a reusable way to filter blog posts and other content collections by various fields in Astro with pagination and meta tags.

This post is part two of the Steal This Blog! Series.
Steal This Blog! Part 2: Filtering Collections by Tags, Authors, and More

While the Piano Pronto Blog isn’t primarily a tech blog, I still find it’s a good place for me to share some of the tech we use at Piano Pronto Publishing, Inc. In this article series, I aim to document the codebase that powers this blog, and some of the possibly unique things I’ve done with Astro framework. View the demo.



Table of Contents:


💎 Introduction

If you read the previous article in the Steal This Blog! Series, you may recall the section about content helper functions. While I had expanded on my custom getCollection function, I only briefly touched on the getTaxonomy and getCollectionFilter functions.

In this article, I will show on how I built custom filtering routes, such as example.com/tags/popular/ and example.com/author/houston/. We will review how Astro uses file-based routing and create a reusable page component that handles filtering blog posts by any field.


🏷️ Getting Unique Values: Know What (Tags) You’re Dealing With

Before we can filter a content collection, we need to know what are the values we actually could filter by.

You’ll find the following function in src/content/utils.js which loops through all items in a collection and collects their unique values for any given schema field, such as tags or author. I’ve added some comments below.

/**
 * Returns all unique values from a collection for a given field
 * @param {string} type - The type of collection such as "blog"
 * @param {string} term - The field to get such as "tags" or "author"
 * @returns {Array<Object>}
 */
export const getTaxonomy = async (type, term) => {
  // Call our custom getCollection() function from src/content/utils.js
  const items = await getCollection(type);

  // Start reducing the array of blog posts into a Map where the value is the count
  const taxonomy = items.reduce((acc, item) => {

    // We want to support both array and reference fields, i.e. both:
    // tags: ["tag 1", "tag 2"]
    // author: "houston"
    // So we're going to convert either of those into an array of values
    const values = [];
    if (Array.isArray(item.data[term])) {
      values.push(...item.data[term]);
    } else if (item.data[term]?.slug) {
      values.push(item.data[term].slug);
    }

    // Loop through each value and increment the count by one
    for (let value of values) {
      const count = acc.get(value) || 0;
      acc.set(value, count + 1);
    }

    // Return our accumulator
    return acc;
  }, new Map());

  // On dev, make sure we have these tags even if there are no posts with them set
  // This is so if we go to /tags/drafts/ when there are no draft posts, we won't 404
  if (term === "tags" && import.meta.env.MODE == "development") {
    if (!taxonomy.has("Drafts")) {
      taxonomy.set("Drafts", 0);
    }
    if (!taxonomy.has("Scheduled")) {
      taxonomy.set("Scheduled", 0);
    }
  }

  // Convert the Map to an array of object and sort by count
  return Array.from(taxonomy, ([value, count]) => ({ value, count })).sort(
    (a, b) => b.count - a.count
  );
};

Here’s an example of the function’s output, nifty huh?

getTaxonomy("blog", "tags");

[
  { "value": "Popular", "count": 3 },
  { "value": "Guides", "count": 2 },
  { "value": "Showcase", "count": 1 },
  { "value": "Drafts", "count": 0 },
  { "value": "Scheduled", "count": 0 }
]

🔎 Applying Filters: Refining the Results

Now that we know what we can filter by, we are ready to explore the getCollectionFilter() function which (as the name implies) filters a collection. This function could be improved to let us define multiple criteria, but I haven’t had the need yet for this blog. I’ve again added some comments below to help explain what’s going on:

/**
 * Returns collection entries matching a value for a given field
 * @param {string} type - The type of collection such as "blog"
 * @param {string} term - The field to search on such as "tags" or "author"
 * @param {string} value - The value to match such as "Popular"
 * @param {boolean} reverse - Sort with oldest posts first
 * @returns {Array<Object>}
 */
export const getCollectionFilter = async (
  type,
  term,
  value,
  reverse = false
) => {
  // Call our custom getCollection() function from src/content/utils.js
  const items = await getCollection(type, true, reverse);

  // Since value might be the human label, or from a URL, we will normalize it to a slug
  const slug = slugify(value);

  // Start filtering the content collection
  return items.filter((item) => {

    // See the comments in the getTaxonomy() function
    const values = [];
    if (Array.isArray(item.data[term])) {
      values.push(...item.data[term]);
    } else if (item.data[term]?.slug) {
      values.push(item.data[term].slug);
    }

    // Does this collection entry contain the value we're looking for?
    return values.map((value) => slugify(value)).includes(slug);
  });
};

Here’s an example of the function’s output, with some of the fields truncated for brevity.

getCollectionFilter("blog", "tags", "popular");

[
  // ...
  {
    "id": "second-post.md",
    "slug": "second-post",
    "body": "...",
    "collection": "blog",
    "data": {
      "title": "Second post",
      "description": "Lorem ipsum dolor sit amet",
      "pubDate": "2022-07-15T07:00:00.000Z",
      "updatedDate": null,
      "heroImage": { ... },
      "author": {
        "slug": "houston",
        "collection": "authors"
      },
      "tags": [ "Popular" ],
      "featured": false,
      "draft": false
    }
  },
  // ...
]

⌚ Astro Routing Review: What’s in a (File) Name?

Now that we’ve built an API for filtering collections, we need to create the actual pages that will show filtered results on the frontend. Before we do that, we should review how Astro handles dynamic routes and nested pagination.

Let’s assume we’ve created a page component at src/pages/tags/[tag].astro. We would then use the getStaticPaths() function to tell Astro that we want to create a page at /tags/popular/, /tags/guides/, and at any other tags we’ve defined. Our file would look something like this:

---
import { getTaxonomy, getCollectionFilter } from "@/content/utils";
export async function getStaticPaths({ paginate, ...rest }) {
  const routes = [];
  const values = await getTaxonomy("blog", "tags");
  for (const { value, count } of values) {
    const posts = await getCollectionFilter("blog", "tags", value);
    const route = paginate(posts, {
      params: { tag: slugify(value) },
      props: { title: value },
      pageSize: consts.PAGE_SIZE,
    });
    routes.push(route);
  }
  return routes;
}

const { tag } = Astro.params;
const { page: pagination } = Astro.props;
const posts = pagination.data;
// ...

Here’s what we’re doing in the code above:

  1. Use our getTaxonomy() function to get an array of tags used in the blog content collection.
  2. Loop through each tag and use our getCollectionFilter() function to get an array of blog posts matching that tag.
  3. Use Astro’s built-in pagination helper to create a page showing the filtered posts.
  4. Load the tag and filtered posts in each individual route.

Note: Our file should have actually been at either src/pages/tags/[tag]/[page].astro or src/pages/tags/[tag]/[...page].astro in order to generate /tags/popular/1/, /tags/popular/2/, etc. See the API Reference for more details.


👓 Abstracting Routes: Taking a Step Back

The code in the previous section would work great if we only wanted to filter by tags. If we then decided we wanted to filter by author, we could just cp -R src/pages/tags src/pages/author and find/replace, but we’d end up duplicating too much code. When you take a moment to think about how Astro’s dynamic routes work, you might know what we will do next.

We are going to abstract the page component and move it to src/pages/[term]/[value]/[...page].astro. This will let us create a getStaticPaths() function that loops through several fields, and loops through all values for those fields. Here’s what it looks like:

import { getTaxonomy, getCollectionFilter } from "@/content/utils";
export async function getStaticPaths({ paginate, ...rest }) {
  const routes = [];
  const terms = ["tags", "author"];
  for (const term of terms) {
    const values = await getTaxonomy("blog", term);
    for (const { value, count } of values) {
      const posts = await getCollectionFilter("blog", term, value);
      const route = paginate(posts, {
        params: { term, value: slugify(value) },
        props: { title: value },
        pageSize: consts.PAGE_SIZE,
      });
      routes.push(route);
    }
  }
  return routes;
}

const { term, value } = Astro.params;
const { page: pagination } = Astro.props;
const posts = pagination.data;
// ...

The two main differences are that we need to set both term and value in the params object, and then subsequently destructure them from Astro.props. Notice how easy it would be to add even more terms to filter by, perhaps something like series or category.


❄️ Defining Differences: No Two Fields are Alike

Code abstraction is great, but what if there are some differences in how we want the tags and the author pages to look? Since we don’t have a huge number of filters yet, it’s not too much of a problem to just use some switch or if statements. If we did have more filters we might want to move logic somewhere else, but for now simple is fine.

First, let’s change some of the meta information depending on the term we’re displaying. I’ll explain how exactly the page object affects meta tags in a future post, but for now just know that we want to pass it over to the layout/Base.astro component.

let title = Astro.props.title,
  description = null,
  image = null;

switch (term) {
  case "tags":
    description = `Explore our blog for captivating articles tagged "${title}."`;
    break;
  case "author":
    const author = await getAuthor(value);
    title = author.data.name;
    image = author.data.image;
    description = `Explore our blog for articles posted by ${title}.`;
    break;
}

const page = {
  data: {
    title:
      title +
      (pagination.currentPage > 1 ? ` | Page ${pagination.currentPage}` : ""),
    description: description,
    image: image ?? import(`../../../assets/meta/${term}.png`),
  },
};

The other main difference is that on the author page, it would be nice to show the author’s bio. Fortunately, that’s pretty simple. Check out our template:

<BaseLayout layout="wide" page={page}>
  <PostGrid
    posts={posts}
    pagination={pagination}
    layout="normal"
    baseUrl={`/${term}/${value}`}
  >
    <div class="prose col-span-full lg:prose-xl">
      <h1>{title}</h1>
      {term === "author" && <AboutAuthor author={value} title="" />}
      <p>
        A collection of {posts.length}
        {posts.length == 1 ? "post" : "posts"}
      </p>
    </div>
  </PostGrid>
</BaseLayout>

With just a single line, we can check if we’re displaying the author term, and if so, include the partials/AboutAuthor.astro component.

There’s one thing we’ve skipped over; how can readers see a list of all tags, authors, or whatever other term we want to encourage them to browse by? While we could most definitely create a page at src/pages/[term]/index.astro that would list all terms with clickable links, I decided that I wanted the index page for each of the terms to look differently. For example, the browse by tags page just shows a list of tags, but the browse by author page might look much more complex with biographies, featured posts by those authors, etc.

As of now, I’ve only implemented src/pages/tag.astro. It’s pretty simple, it just calls our getTaxonomy() function and loops through each tag rendering the partials/Tag.astro helper component. I’m sure I’ll be implementing a browse by author page soon.


💡 Conclusion

When I was evaluating a few different themes (before I decided to write my own,) many of the theme authors opted for the copy/paste approach under the pages directory for the different filters. I felt that would cause more work on my end to customize each of those routes, and would lead to higher maintenance. In fact, after I had launched the blog, I decided I wanted a third filterable field series (in fact this very article is in a series.) At that point, I was glad that I spent the time abstracting the code.

Hopefully this article is helpful to not only those using the Steal This Blog! theme, but for anyone else building a blog using Astro. I hope it can make you step back and think about what the getStaticPaths() function is really capable of.



Justin Beaty
Written by Justin Beaty
As the sole developer at Piano Pronto, Justin is a master of bringing life to our website and ensuring it's the best possible experience of shopping for sheet music online. In a world of algorithms and APIs, Justin is our digital maestro, orchestrating every line of code to perfection. He's a transplant to Southern California from the East Coast, and never complains about the weather.

Comments

User's avatar


No comments yet
Be the first to comment.

Steal This Blog! Part 3: Partial Components and Reusable UI
Nerd Corner

Steal This Blog! Part 3: Partial Components and Reusable UI

An overview of the seven most used components from this theme, with full source code of each component, and discussion of their most interesting features.

  • 11 min read
Read more
Steal This Blog! Part 1: Overview, Content Collections, Pages, and Components
Nerd Corner

Steal This Blog! Part 1: Overview, Content Collections, Pages, and Components

Recreating Ghost's Casper theme for Astro, with support for tags, author pages, scheduled posts, tailwind, and more.

  • 17 min read
Read more
Tribute to Gordon Lightfoot
Tributes
Pop Sheet Music
1970s Sheet Music

Tribute to Gordon Lightfoot

A brief overview of Gordon Lightfoot's life and career and piano sheet music arrangements of his mega-hits "If You Could Read My Mind" and "Sundown."

  • 7 min read
Read more