Building a Blog with Next.js App Router and MDX - Part 2: Tags

Published:

thumbnail

In December, I published the first part of a walkthrough for setting up MDX to dynamically import blog content in the Next.js App Router, at the end of which I promised a walkthrough on setting up a tags page and filtering by tag. This article assumes you have followed along with that guide, or are using the template repository available on my GitHub.

Utility Functions

The first thing we need to build here are the utility functions that allow us to count tags out of an array of articles. For this tutorial, I've placed them in src/app/_utils/tags.ts, next to articles.ts that we built in the last tutorial. We'll be working with a simple type that includes just the tag itself, and the count of how many times that tag has been used in an article:

export type TagCount = { tag: string; count: number }

Next, we need a way to update an array of tag objects with any new tags we found on a given article. This code will iterate through the new tags (pulled from the tags metadata on an article file), search for an existing instance of the tag in the array and update the count if it's there, and if not, create a new tag count object.

export function updateTagCounts(tags: TagCount[], newTags: string[]) {
  for (const newTag of newTags) {
    const existingTag = tags.find((tag) => tag.tag === newTag)
    if (existingTag) {
      existingTag.count += 1
    } else {
      tags.push({ tag: newTag, count: 1 })
    }
  }
}

It's also handy to have a function to sort the tag count objects so you can display the tags with higher quantities of posts first:

export function sortTagCounts(tagA: TagCount, tagB: TagCount): number {
  return tagB.count - tagA.count
}

Then, we write a function that lets us take an array of article objects, get the tag array out of it, and add them to its internal array of tag count objects. Note that you could inline updateTagCounts into this function as it isn't used elsewhere, but I have them separate to facilitate easier unit tests.

export function aggregateTags(articles: Article[]) {
  const tags: TagCount[] = []

  for (const article of articles) {
    if (article.data.tags && article.data.tags.length > 0) {
      updateTagCounts(tags, article.data.tags)
    }
  }

  return tags.sort(sortTagCounts)
}

If you want to perform additional filtering or sorting logic here, you could do that too - for example filtering out draft posts from the array of tag counts. In the case of the example code I've implemented in the template and this walkthrough, though, that filtering is occurring at a higher level, before the array gets passed down to aggregateTags.

The last utility function we'll need is a way to filter an array of articles by a given tag.

export function filterArticlesByTag(
  articles: Article[],
  tag: string,
): Article[] {
  return articles.filter((article) => {
    return article.data.tags && article.data.tags.includes(tag)
  })
}

A final caution: the generated tags are case specific (so react and React would be two separate tag listings), and adding spaces or other special characters that are not URL safe to a tag could cause issues, broken links, or even your site getting blocked by firewall or anti-malware software. (If you're curious for more information, here's a nice article on Sucuri.net by Marc Kranat going over safe URL characters at a high level.) If you can't guarantee that tags will be consistently cased and URL safe, you could implement additional logic here to handle it, although you would have to decide on tradeoffs such as whether you want the tag list to display all in one case (for example, all lower case), or whether you want to create an additional field in the tag object and have both the tag itself for the route segment, plus the display name. In the latter case you'd still have to decide how to handle conflicts and which display name has priority as well. Steps like these might be necessary if, for example, the blog will have multiple authors who can add arbitrary tags whenever they want.

Tags Pages

Now that our functions are ready to go, let's build the pages that use them. We'll need two route pages (page.tsx), at src/app/tags for the page that lists all tags, then src/app/tags/[tag] to generate dynamic route segments for each tag. These are fairly simple pages as there's not a lot of information to worry about.

Tags List Page

First, in src/app/tags/page.tsx, we'll get the list of articles as we do in src/app/articles/page.tsx (described in the previous section of the walkthrough), then aggregate our tag counts out of them:

import { getArticles } from '@/app/_utils/articles'
import { aggregateTags } from '@/app/_utils/tags'

export default function Tags() {
  const articles = getArticles()

  const tagCounts = aggregateTags(articles)

  // ...
}

Then for each object returned, we just need to render a card on the page. It's inlined in this example, but you could also create a component to render for each one to streamline this page even further (which is how the template repository does it).

// Add the Link import at the top of the file:
import Link from 'next/link'

// The return from the Tags function component:
return (
  <>
    <h1>Tags</h1>
    {tagCounts.map((tagCount) => (
      <div key={tagCount.tag}>
        <Link href={`/tags/${tagCount.tag}`}>
          {tagCount.tag}{' '}
        </Link>
        <small>({tagCount.count} articles)</small>
        <br />
      </div>
    ))}
  </>
)

Now, if you load http://localhost:3000/tags, you should see something like this, depending on what test articles you have added:

Screenshot of page showing list of tags rendering successfully

Individual Tag Page

You may have noticed at the end of the last stage, if you click one of these tag links, you get a 404. Let's fix that next. Create a file at src/app/tags/[tag]/page.tsx. This doesn't need to be a catch-all route segment like we mentioned before, unless you decide to go with some kind of nested tag structure (which isn't covered here), just a regular dynamic route segment. First, we'll fetch our articles and filter using the tag as the parameter, using getArticles that we built last time and filterArticlesByTag from earlier in this post:

import { getArticles } from '@/app/_utils/articles'
import { filterArticlesByTag } from '@/app/_utils/tags'

export default function TagPage({ params }: { params: { tag: string } }) {
  const articles = filterArticlesByTag(getArticles(), params.tag)
  // ..
}

Then, we'll add some rendering for the tag information in the return of the TagPage function:

return (
  <>
    <h1>Articles Tagged {params.tag}</h1>
    {articles.length > 0 && <small>Found: {articles.length} articles</small>}
    <br />
    {articles.length === 0 && <strong>No articles found.</strong>}
    {/* We'll render the list of article cards here.*/}
  </>
)

If this is all working properly, assuming you have at least one markdown file tagged test, navigating to http://localhost:3000/tags/test should show you something like this, with appropriate numbers based on your content/articles/ directory:

Screenshot of page showing number of posts tagged test

Lastly, this page will reuse some code from src/app/articles/page.tsx to display all of the articles with a given tag. We'll inline the code here, but this is a great place to make a component file (such as ArticleCard in the template repository), to reduce duplication and ensure your articles list and tag pages have a consistent style. The map will look something like this, dropped in place of the comment in the above return statement:

{articles.map((article) => (
  <Link
    href={`/articles/${article.slug}`}
    data-testid={'article-card'}
    key={article.slug}
  >
    <div
      style={{
        border: 'gray solid 1px',
        margin: '1em',
        padding: '0.75em',
        display: 'flex',
        flexDirection: 'row',
      }}
    >
      <div style={{ flex: 'auto' }}>
        <h3>{article.data.title}</h3>
        <p>{article.data.description}</p>
        <p>
          <small>{article.data.publishedDate.toLocaleDateString()}</small>
        </p>
        <p>
          <small>
            Tags:{' '}
            {article.data.tags.map((tag: string, index: number) => {
              return (
                <span key={tag}>
                  {tag}
                  {index !== article.data.tags.length - 1 && ', '}
                </span>
              )
            })}
          </small>
        </p>
      </div>
    </div>
  </Link>
))}

Success! Refresh http://localhost:3000/tags/test and you will now see a list of articles tagged test, depending on what's in your content/articles/ directory:

Screenshot of page showing list of posts tagged test

That's it for now on this blog building journey. I have a few more experiments to try in the upcoming months, which if I'm successful I'll do my best to make time for future articles on. I hope this was useful and helpful to you!

This post is tagged: