Filtering by Tags in your NextJS MDX Blog

Filtering by Tags in your NextJS MDX Blog

I've been wanting to make articles on this blog a little bit more navigable / organized, and I decided that adding tags would probably be a pretty low friction way to do that.

Let me show you how I did it.

Adding Tags to MDX Articles

Let's add some tags to our articles.

Since we're already using front-matter, all we need to do is add a tags property to our meta.

/src/content/articles/my-article.mdx
---
title: 'My Article'
date: '2021-01-01'
tags: nextjs, mdx, react
---

Now, let's put together a simple component that will display the tags on our <ArticleLayout /> component.

src/components/ArticleTags.jsx
import React from 'react'

export function ArticleTags({ tags }) {
  if (!tags?.length) {
    return null
  }

  tags = tags.split(',').map((t) => t.trim())

  return tags.map((tag, i) => (
    <span
      key={i}
      className="ml-2 rounded-full bg-indigo-100 px-3 text-[11px] font-light text-indigo-500 ring-1 ring-indigo-300"
    >
      {tag}
    </span>
  ))
}

Let's add that to our <ArticleLayout /> component.

src/components/ArticleLayout.jsx
// ...
import { ArticleTags } from '@/components/ArticleTags'

export function ArticleLayout({
  children,
  meta,
  isRssFeed = false,
  previousPathname,
}) {

  // ...

  return (
    {/* ... */}
    <article>
      <header className="flex flex-col">
        <h1 className="mt-6 text-4xl font-bold tracking-tight text-zinc-800 dark:text-zinc-100 sm:text-5xl">
          {meta.title}
        </h1>
        <time
          dateTime={meta.date}
          className="order-first flex items-center justify-between text-base text-zinc-400 dark:text-zinc-500 print:hidden"
        >
          <span className="h-4 w-0.5 rounded-full bg-zinc-200 dark:bg-zinc-500" />
          <span className="ml-3 grow">{formatDate(meta.date)}</span>
          <ArticleTags tags={meta.tags} />
          {meta.author !== 'Ty Barho' ? (
            <span className="text-xs bg-indigo-100 text-indigo-600 rounded-lg px-2">by {meta.author}</span>
          ) : null}
        </time>
      </header>
      <Prose className="mt-8">{children}</Prose>
    </article>
    {/* ... */}
  )
}

That should give us something that looks like this:

Simple Tags Render

Nice! 😎

Filtering Articles by Tag

Lastly, let's make sure users can filter articles by tag on our main /articles route.

Conceptually, the list could be filtered based on a query string in the URL, like this:

/articles?tag=nextjs

However, when using getStaticPaths in NextJS to build static pages, we can't use query strings in the URL, so we'll need to use a different approach.

One way we can do this is by creating a new URL structure for our tagged article routes. Something like:

/[tag]/articles

This way, we can implement the dynamic tag pages, and still render out our same components on statically generated pages.

Let's get started.

Getting Around getStaticPaths with Tags

First, let's create our new dynamic route for our tag pages.

We want it to look similar to our /articles route, so let's reuse the components from there.

pages/[tag]/articles.jsx
import { NextSeo } from 'next-seo'

import { SimpleLayout } from '@/components/SimpleLayout'
import { Article } from '@/components/Article'
import { getAllContent } from '@/lib/getContent'

export default function ArticlesIndex({ articles }) {
  const title = 'Articles - Ty Barho'
  const description =
    'Some of my long-form thoughts on software, dad-hacking, theology, and more, collected in chronological order.'
  const canonical = 'https://tybarho.com/articles'

  return (
    <>
      <NextSeo
        title={title}
        description={description}
        canonical={canonical}
        openGraph={{
          url: canonical,
          title,
          description,
        }}
      />
      <SimpleLayout
        title="Writing on Javascript, dad-hacking, and spiritual formation."
        intro="Some of my long-form thoughts on software, dad-hacking, theology, and more, collected in chronological order."
      >
        <div className="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40">
          <div className="flex max-w-3xl flex-col space-y-16">
            {articles.map((article) => (
              <Article key={article.slug} article={article} />
            ))}
          </div>
        </div>
      </SimpleLayout>
    </>
  )
}

export async function getStaticProps() {
  return {
    props: {
      articles: (await getAllContent('articles')).map(
        ({ component, ...meta }) => meta
      ),
    },
  }
}

Now let's try it out by navigating to http://localhost:3000/nextjs/articles.

getStaticPaths Error

No worries. NextJS is just telling us that we need to implement a getStaticPaths function to generate the static pages for each tag.

First, though, we need a function to get all of the tags from our articles. Let's add that to our /lib/getContent.js file.

// /src/lib/getContent.js

// ...

// Add the getSlugs function
async function getSlugs(srcDir) {
  return (
    await glob(['*.mdx', '*/index.mdx'], {
      cwd: path.join(process.cwd(), `src/content/${srcDir}`),
    })
  ).map((n) => n.replace(/\.mdx$/, ''))
}

// A function to gather all tags from articles
export async function getAllTags(srcDir) {
  let slugs = await getSlugs(srcDir)

  const cb = (slug) => getContentItem(slug, srcDir)

  function reducer(memo, item) {
    if (!item?.data?.tags?.length) {
      return memo
    }

    const itemTags = item.data.tags
      .split(',')
      .map((tag) => tag.trim().toLowerCase())

    itemTags.forEach((tag) => {
      if (!memo.includes(tag)) {
        memo.push(tag)
      }
    })

    return memo
  }

  const items = await Promise.all(slugs.map(cb))
  const tags = items.reduce(reducer, [])

  return tags.sort()
}

export async function getAllContent(srcDir) {
  // Update getAllContent to use getSlugs
  let slugs = await getSlugs(srcDir)

  const cb = (slug) => getContentItem(slug, srcDir)

  let items = await Promise.all(slugs.map(cb))

  return items
    .filter((item) => item.data.date)
    .sort((a, z) => new Date(z.data.date) - new Date(a.data.date))
}

Now, we need to add the getStaticPaths function to our /src/pages/[tag]/articles.jsx file.

// /src/pages/[tag]/articles.jsx

// ...

export async function getStaticPaths() {
  const tags = await getAllTags('articles')
  const paths = tags.map((tag) => `/${tag}/articles`)

  return {
    paths,
    fallback: false,
  }
}

export async function getStaticProps() {
  return {
    props: {
      articles: (await getAllContent('articles')).map(
        ({ component, ...meta }) => meta
      ),
    },
  }
}

Now if you refresh the page, you should see an exact duplicate of the /articles page, but with the tag name in the URL.

Tagged Articles Page

This is because we are missing 2 key items:

  1. We need to filter the articles by tag in the getStaticProps function
  2. We need to update our /[tag]/articles.jsx render to show a different UX for "filtered" articles

Let's start with the first one.

Filtering Articles by Tag

We need to update our getStaticProps function to filter the articles by tag.

// /src/pages/[tag]/articles.jsx

// ...

export async function getStaticProps({ params: { tag } }) {
  return {
    props: {
      articles: (await getAllContent('articles'))
        .map(({ component, ...meta }) => meta)
        .filter(
          (meta) =>
            meta?.data?.tags &&
            meta?.data?.tags
              .split(',')
              .map((t) => t.trim().toLowerCase())
              .includes(tag)
        ),
    },
  }
}

Now, if you visit http://localhost:3000/nextjs/articles you should only see articles with the nextjs tag.

Tagged Articles Page

Great! 💪

Now let's update our render to show a different UX for our "filtered" articles.

Updating the UX for Filtered Articles

To start, we want to update the title and intro text to reflect the tag we are filtering by.

We first need to be sure the tag is getting passed to the component from getStaticProps.

// /src/pages/[tag]/articles.jsx

// ...

export async function getStaticProps({ params: { tag } }) {
  return {
    props: {
      tag, // <-- Add this
      articles: (await getAllContent('articles'))
        .map(({ component, ...meta }) => meta)
        .filter(
          (meta) =>
            meta?.data?.tags &&
            meta?.data?.tags
              .split(',')
              .map((t) => t.trim().toLowerCase())
              .includes(tag)
        ),
    },
  }
}

Now, in our <ArticlesIndex /> component, we can change the details to reflect the tag.

// /src/pages/[tag]/articles.jsx

// ...

export default function ArticlesIndex({ articles, tag }) {
  const title = `${tag} Articles - Ty Barho`
  const description = `Some of my long-form thoughts on ${tag}`
  const canonical = `https://tybarho.com/${tag}/articles`

  return (
    <>
      <NextSeo
        title={title}
        description={description}
        canonical={canonical}
        openGraph={{
          url: canonical,
          title,
          description,
        }}
      />
      <SimpleLayout
        title={
          <>
            Articles tagged with:{' '}
            <span className="ml-4 rounded-full bg-pink-100 px-8 py-1 text-4xl font-light text-pink-600 ring-1 ring-pink-300">
              {tag}
            </span>
          </>
        }
        intro={`Here is a list of all articles I've written on the topic of ${tag}.`}
      >
        <div className="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40">
          <div className="flex max-w-3xl flex-col space-y-16">
            {articles.map((article) => (
              <Article key={article.slug} article={article} />
            ))}
          </div>
        </div>
      </SimpleLayout>
    </>
  )
}

This should render out something like this...

Tagged Articles Page

Adding Tag Links to our Main Articles Page

Now that we have our tag links working, we want to add them to our main /articles page.

Let's utilize the <ArticleTags /> component we used earlier, in conjunction with our getAllTags function.

import { NextSeo } from 'next-seo'

import { SimpleLayout } from '@/components/SimpleLayout'
import { Article } from '@/components/Article'
import { ArticleTags } from '@/components/ArticleTags'
import { getAllContent, getAllTags } from '@/lib/getContent'

export default function ArticlesIndex({ articles, tags }) {
  const title = 'Articles - Ty Barho'
  const description =
    'Some of my long-form thoughts on software, dad-hacking, theology, and more, collected in chronological order.'
  const canonical = 'https://tybarho.com/articles'

  return (
    <>
      <NextSeo
        title={title}
        description={description}
        canonical={canonical}
        openGraph={{
          url: canonical,
          title,
          description,
        }}
      />
      <SimpleLayout
        title="Writing on Javascript, dad-hacking, and spiritual formation."
        intro={
          <>
            Some of my long-form thoughts on software, dad-hacking, theology, and more, collected in chronological order.
            <div className="py-4">
              <ArticleTags tags={tags} />
            </div>
          </>
        }
      >
        <div className="md:border-l md:border-zinc-100 md:pl-6 md:dark:border-zinc-700/40">
          <div className="flex max-w-3xl flex-col space-y-16">
            {articles.map((article) => (
              <Article key={article.slug} article={article} />
            ))}
          </div>
        </div>
      </SimpleLayout>
    </>
  )
}

export async function getStaticProps() {
  const tags = await getAllTags('articles')
  const articles = (await getAllContent('articles')).map(
    ({ component, ...meta }) => meta
  )

  return {
    props: {
      tags,
      articles,
    },
  }
}

When we check the page... oops. We get this:

Tags Split Error

Looks like our ArticleTags component is expecting a string, but we are passing it an array of objects.

Let's fix that.

// /src/components/ArticleTags.jsx

import React from 'react'

export function ArticleTags({ tags }) {
  if (!tags?.length) {
    return null
  }

  // Check the type of tags
  if (typeof tags === 'string') {
    tags = tags.split(',').map(t => t.trim().toLocaleLowerCase());
  } else {
    tags = tags.map(t => t.trim().toLocaleLowerCase());
  }

  return tags.map((tag, i) => (
    <span key={i} className="text-[11px] font-light text-pink-600 bg-pink-100 ring-1 ring-pink-300 px-3 rounded-full ml-2">
      {tag}
    </span>
  ))
}

Now, let's make the tag chips clickable links with next/link's <Link /> component.

// /src/components/ArticleTags.jsx

import React from 'react'
import Link from 'next/link'

export function ArticleTags({ tags }) {
  if (!tags?.length) {
    return null
  }

  if (typeof tags === 'string') {
    tags = tags.split(',').map((t) => t.trim().toLocaleLowerCase())
  } else {
    tags = tags.map((t) => t.trim().toLocaleLowerCase())
  }

  return tags.map((tag, i) => (
    <Link
      key={i}
      href={`/${tag}/articles`}
      className="ml-2 rounded-full bg-pink-100 px-3 py-1 text-[11px] font-light text-pink-600 ring-1 ring-pink-300"
    >
      {tag}
    </Link>
  ))
}

That should give you something like this:

Clickable Tags

Nice!

Finally, Adding Our New Routes to the Sitemap

Now, let's generate our sitemap using

$ yarn build

Check your /public/sitemap-0.xml file, and see if your new url's were picked up.

Sitemap with Tags

Hooray, looks like we're good to go! Now you can tag articles to your ❤️'s content!

Happy coding! 🚀

If you enjoyed this article, please consider following me on Twitter

Subscribe to the Newsletter

Subscribe for exclusive tips, strategies, and resources to launch, grow, & build your SaaS team.

Share this article on: