Avatar

Nǐ hǎo, I'm Julia.

Creating Dynamic Tag Pages With NextJS Nested Routes

#nextjs #react #typescript

6 min read

If you've seen my blog, you'll notice that I tag all my blog posts with relevant tags, like "react-native" or "typescript". Up to now, those tags never really served a purpose, apart from to communicate to readers, roughly speaking, what the blog post pertains to.

After completing and deploying a search function for my blog, I finally decided to build out functionality for my blog post tags. In terms of user design, I figured something like this would serve as a good starting point:

  • Each tag on a blog post should be clickable.
  • Upon clicking the tag, the user should be able to see a list of other blog posts tagged with the same tag.
  • The user should be able to navigate directly to a blog post from that view.

I initially toyed with the idea of having popup results, but decided that the simplest and most intuitive design for a user would be to implement tag pages that would be accessible via a URL. Each tag page would then list all the blog posts with that particular tag. One key requirement I wanted was for the generation of these tag pages to be dynamic - I already have quite a number of tags in use, but also foresee myself adding new tags in the future and wanted to make it easy to maintain going forward.

Here are the steps I took to implement this. The TL;DR is that this was achieved with NextJS's concept of dynamic and nested routes.

Step 1: Define nested routes for your tag pages

In my NextJS blog, I wanted my tag page URLs to be located at something like https://bionicjulia.com/blog/tags/react-native. My blog is located at https://bionicjulia.com/blog. Routes in NextJS are defined in the pages folder, so to do this, create a folder called tags within pages > blog. Within the new tags folder, create a new file called [tag].tsx. The square brackets tells Next that this is a template, to be replaced with a dynamic name.

Step 2: Create the template for the tag page

To define what the layout of each of the tag pages will look like, open the newly create [tag].tsx file and create a component for rendering the HTML you want (I called mine BlogTag). I wanted my layout to pretty much mirror that of my /blog page, so I just needed to reuse my BlogPosts component. (Please refer to my previous blog post on setting up a markdown blog on NextJS if you're not familiar with how to set this up.)

Part of the setup involves defining getStaticProps (to get the individual props required for each tag, to pass into the BlogTag component) and getStaticPaths (since this is a dynamic route, we need to let NextJS know what the valid paths will be when it does its build).

import BlogPosts from '@/components/BlogPosts'
import SiteContainer from '@/components/SiteContainer'
import { getAllPostsWithFrontMatter, getTags } from '@/lib/utils'
import { Params } from 'next/dist/next-server/server/router'
import React from 'react'
import { BlogTagProps } from 'types'

export default function BlogTag({ posts, title, description, tag }: BlogTagProps) {
  return (
    <SiteContainer title={title} description={description}>
      <div className="mb-6">
        <section>
          <h3>✍🏼 Blog posts tagged "{tag}"</h3>
          <BlogPosts posts={posts} />
        </section>
      </div>
    </SiteContainer>
  )
}

export async function getStaticProps({ params }: Params) {
  const posts = await getAllPostsWithFrontMatter('blog', params.tag)

  return {
    props: {
      posts,
      title: `Blog Posts - ${params.tag}`,
      description: `Posts on software engineering for tag ${params.tag}`,
      tag: params.tag,
    },
  }
}

export async function getStaticPaths() {
  const tags = await getTags('blog')

  const paths = tags.map((tag: string) => ({
    params: {
      tag,
    },
  }))

  return {
    paths,
    fallback: false,
  }
}

For getStaticProps , I have a utility method getAllPostsWithFrontMatter which I already use for listing all my posts at /blog. I amended this to allow for a tag string to be passed into the method, so that the blog posts returned from the method would be filtered to only include ones that have been relevantly tagged.

export async function getAllPostsWithFrontMatter(dataType: string, filterByTag: string | null = null) {
  const files = fs.readdirSync(path.join(root, 'data', dataType))

  return files.reduce((allPosts, postSlug) => {
    const source = fs.readFileSync(path.join(root, 'data', dataType, postSlug), 'utf8')
    const { data } = matter(source)

    if (filterByTag) {
      if (data.tags.includes(filterByTag)) {
        return [
          {
            frontMatter: data,
            slug: postSlug.replace('.md', ''),
          },
          ...allPosts,
        ]
      } else {
        return allPosts
      }
    }

    return [
      {
        frontMatter: data,
        slug: postSlug.replace('.md', ''),
      },
      ...allPosts,
    ]
  }, [])
}

For getStaticPaths, I use another utility method called getTags which in turn calls collateTags. The collateTags method basically:

  • Gets all the files in the relevant directory (blog).
  • Maps through each file to "read" the contents and get the tags from the frontmatter.
  • Create a Set and add all the tags from each post to the set (using a set means that only unique tags can be added to it).
  • The return value is an array (which is created from the set).

Note that the reason I have an intermediate getTags method is to make it easier for when I expand this tags functionality to other parts of my website like /books.

async function collateTags(dataType: string) {
  const files = fs.readdirSync(path.join(root, 'data', dataType))
  let allTags = new Set<string>() // to ensure only unique tags are added

  files.map((postSlug) => {
    const source = fs.readFileSync(path.join(root, 'data', dataType, postSlug), 'utf8')
    const { data } = matter(source)

    data.tags.forEach((tag: string) => allTags.add(tag))
  })

  return Array.from(allTags)
}

export async function getTags(dataType: string) {
  const tags: TagOptions = {
    blog: await collateTags('blog'),
		// books: await collateTags('books'),
  }
  return tags[dataType]
}

Aside: If you're interested in how I defined TagOptions:

export type TagOptions = {
  [key: string]: string[],
}

Step 3: Amend HTML to include links to tag pages

As mentioned in step 1, BlogPosts is the component I use to render the list of relevant blog posts. I amended it to map through a post's tags and render a Link to /blog/tags/${tag}. (Note that I use CustomLink which is a custom component I created, but for all intents and purposes, it's the NextJS Link component.)

import { BlogPostsProps } from 'types'
import CustomLink from './CustomLink'

const BlogPosts = ({ posts }: BlogPostsProps) => {
  return (
    <div>
      // ... blah blah
      <ul className="pl-0">
        {posts &&
          posts.map((post) => {
            return (
              <article key={post.slug} className="my-6 post-title">
                // ... blah blah
                <p className="my-0">
                  {post.frontMatter.tags.map((tag) => (
                    <CustomLink key={tag} href={`/blog/tags/${tag}`}>
                      #{tag}{' '}
                    </CustomLink>
                  ))}
                </p>
              </article>
            )
          })}
      </ul>
    </div>
  )
}

export default BlogPosts

And that's pretty much it! I also show tags on each individual blog post's page, so on a similar note, I also amended the layout here to include Links to the tag pages.

Any comments or improvement points? I'd love to hear from you on Twitter or Instagram.

© 2016-2024 Julia Tan · Powered by Next JS.