Facade of the modern architecture

Building a Custom Category and Tag System in Next.js Blogs

by Nikita Verkhoshintcev

I migrated my website from WordPress to Next.js several years ago, and continued to blog occasionally, but I haven't optimized it effectively.

Unlike WordPress, Next.js doesn't offer such things as pagination, categorization, and a tag system out of the box.

I recently implemented some of the blog-related functionality and would like to publish a series of posts on the topic.

In the first post, I'll share how to build a custom category and tag system for the Next.js blog to improve the readers' experience.

From WordPress to Next.js

Let's touch on the required prerequisites and the tech stack I currently use for this website.

As mentioned above, I previously had a WordPress site. At the beginning, when I had just started freelancing, I built a custom theme, partially to learn how to do it myself.

Then, I picked one of the templates from the marketplace, and that has been a great time-saver, because the templates didn't cost much but offered quite a lot of functionality out of the box.

I used the WYSIWYG editor and composed the UI with the building blocks.

It worked well, but the web performance was not optimal. When you continually add new features to the platform, the costs and complexity accumulate quickly, making it less scalable.

Around 2021, I also published a post about WordPress vs. JAMStack.

JAM stands for JavaScript, API, and Markup.

That's a different architectural paradigm for building websites.

Once we implemented server-side rendering (SSR), it solved many of the problems we had before, as we could now pre-render all static pages to boost performance. Most importantly, SSR resolved SEO-related issues.

I won't delve into this topic in this post.

2021 was also the year when I decided to rebuild my website using that approach.

The Current Website Tech Stack

As a freelancer, I'm overloaded with work. Over the years, I have consistently delivered projects for various customers, worked on business development, bootstrapped a SaaS product, and continually learned new things.

So, I didn't have much time to invest in migration and maintenance.

Thankfully, around that time, I worked on a project where we implemented a Salesforce customer community using React, MaterialUI, Next, and AWS architecture.

It was a greenfield research and development project, but by utilizing a modern tech stack, we delivered a significant number of features in a relatively short amount of time.

I first encountered Next.js on that project and fell in love with it, because I have always been comfortable working with JavaScript and various frameworks. Still, this one added a missing capability to generate static websites and manage user interfaces.

Since I didn't have much free time, I opted for Next.js, hosted it on Vercel, and selected TailwindCSS for styling, as it provides a wide range of ready-made components.

Now, reflecting on that decision, I don't like how verbose Tailwind is, but it did its job.

Regarding the blogging capabilities, I've installed the next-sitemap to generate the sitemap after the build, gray-matter to parse the front-matter from the post files, and react-markdown with rehype-highlight and rehype-raw plugins to render the markdown.

I don't rely on the third-party CMS and manage my posts using the markdown files. Then, the Next.js engine processes them and generates the static pages, which results in a fast and responsive web application.

As a recap, here is the tech stack and NPM packages that this website uses:

  • Vercel
  • React
  • Next.js
  • Tailwind
  • react-markdown
  • gray-matter

Why Do Blogs Need Taxonomy?

Why do we even need a category system on the website?

Since you have a large number of inner links, does taxonomy improve your SEO in any way?

I'm not an expert on SEO, but after researching the topic, it doesn't currently impact the rankings.

The primary purpose of categorization and tag systems is to provide a better user experience for visitors, enabling them to navigate the website more easily and find relevant content.

One important note about the SEO and user experience.

Since we know there are no benefits for rankings, there is no point in providing as many categories and tags as possible.

The primary goal is to improve the user experience, and when designing the system, ultimately consider the end-user and what they would prefer.

For example, consider the following example with similar keywords.

{
  "category": "User Experience",
  "tags": ["UX", "User Experience", "Data Visualization"]
}

It's a bad practice because "UX" and "User Experience" are the same terms and will have duplicated content. There is no need to split it up for the readers.

The best approach is to consider the general category to which the post belongs and a set of more specific tags.

Here is how you can optimize the example from above.

{
  "category": "User Experience",
  "tags": ["Data Visualization"]
}

Having well-structured and organized content on the website will ultimately improve its overall performance.

Next.js Categorization Implementation

As mentioned in the first section, I manage my articles in the form of markdown files.

Each post has a front-matter section where I describe the metadata for the post, including its title, published date, and other relevant details.

It's also where I list categories and tags.

Here is an example of the front matter that I have.

---
title: "Memoization for Lightning Web Components"
date: "2025-05-05"
category: "Lightning Web Components"
color: "bg-yellow-100 text-yellow-800"
tags:
  - "Salesforce"
  - "JavaScript"
  - "Best Practice"
coverImage: "memoization-for-lightning-web-components.jpg"
description: "How to implement a memoization function for Lightning Web Components to cache Salesforce Apex methods?"
---

As you can see, it falls under the "Lightning Web Components" category and includes a list of relevant tags.

How would you like to implement the category page?

The target is to have a single categories page that lists all available categories on the website, and then dynamic pages for each category to render the posts that belong to it.

Next.js provides extremely useful getStaticProps and getStaticPaths functions to enable server-side rendering.

The first one allows you to pre-fetch the data for a page.

With the other one, you can pre-fetch the data and dynamically pre-render all static paths that you want.

For example, this is what I use for the posts inside [postname].js.

export async function getStaticPaths() {
  const blogSlugs = ((context) =>
    context.keys().map((key) => key.replace(/^.*[\\\/]/, "").slice(0, -3)))(
    require.context("../../posts", true, /\.md$/)
  );

  const paths = blogSlugs.map((slug) => `/blog/${slug}`);

  return {
    paths,
    fallback: false,
  };
}

It generates the paths and pre-renders static pages for all posts that I've published.

Let's use getStaticProps to fetch the data for the categories page.

I've created an index.js file in pages/blog/categories/. It's the page where we want to fetch all categories from the website and render them as a list.

export async function getStaticProps() {
  const categories = ((context) => {
    const keys = context.keys();
    const values = keys.map(context);
    const categories = {};

    for (let i = 0; i < keys.length; i++) {
      const value = values[i];
      const category = matter(value.default)?.data?.category || "Unknown";
      const slug = category.toLowerCase().replace(/\s+/g, "-");
      if (!categories[slug]) {
        categories[slug] = {
          count: 0,
          name: category,
          slug,
        };
      }
      categories[slug].count++;
    }

    return categories;
  })(require.context("../../../posts", true, /\.md$/));

  return {
    props: {
      categories,
    },
  };
}

The matter() function reads the front matter and returns it in JSON format.

We use a simple loop to group posts into categories and keep track of their counts, allowing us to display on the page the number of posts belonging to each category.

If the category is missing from the front matter, we assume it is "Unknown" by default.

As a result of this function, the React page component has a categories prop that we can use for rendering.

I won't paste the entire page component here, but I'll include the most crucial parts.

export default function Categories({ categories }) {
  return (
    // Render the list of categories
    <div className="my-12 max-w-lg mx-auto lg:max-w-none">
      <ul role="list" className="mt-8 space-y-6">
        {Object.keys(categories)
          .sort()
          .map((slug) => (
            <li key={slug} className="flex justify-between">
              <Link href={`/blog/categories/${slug}`}>
                {categories[slug].name}&nbsp;
                <span className="inline-flex items-center rounded-md bg-blue-400/10 px-2 py-1 text-xs font-medium text-blue-500 inset-ring inset-ring-blue-400/20">
                  {categories[slug].count}
                </span>
              </Link>
            </li>
          ))}
      </ul>
    </div>
  );
}

Alright, now we have the categories component. You can also check it out here: https://digitalflask.com/blog/categories.

Let's then implement the individual category page.

Add the [category].js file inside the pages/blog/categories/ directory

Now we know that it's a dynamic page, and we need to generate the static paths so that Next.js can pre-render them.

We need to fetch all posts, read their front matter, and keep unique keywords that we can achieve with a Set.

Then, we need to convert category names to slugs and generate a list of paths.

export async function getStaticPaths() {
  const categoriesSlugs = ((context) => {
    const keys = context.keys();
    const values = keys.map(context);
    return [
      ...new Set(
        keys.map(
          (_, index) =>
            matter(values[index].default)
              .data.category?.toLowerCase()
              .replace(/\s+/g, "-") || ""
        )
      ),
    ];
  })(require.context("../../../posts", true, /\.md$/));

  const paths = categoriesSlugs.map((slug) => `/blog/categories/${slug}`);

  return {
    paths,
    fallback: false,
  };
}

For each given category, we then need to render its name and a list of posts.

We can again fetch all the posts, filter them by the given category, and sort by the published date.

export async function getStaticProps({ ...ctx }) {
  const { category } = ctx.params;
  const posts = ((context) => {
    const keys = context.keys();
    const values = keys.map(context);
    return keys
      .map((key, index) => {
        const slug = key.replace(/^.*[\\\/]/, "").slice(0, -3);
        const value = values[index];
        const document = matter(value.default);
        return {
          frontmatter: document.data,
          markdownBody: document.content.substring(0, 160),
          slug,
        };
      })
      .filter(
        (post) =>
          post.frontmatter?.category?.toLowerCase().replace(/\s+/g, "-") ===
          category
      )
      .sort(
        (a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date)
      );
  })(require.context("../../../posts", true, /\.md$/));

  return {
    props: {
      posts,
      category,
    },
  };
}

As a result, we have the category and its posts as component props.

export default function Category({ posts, category }) {
  const title = posts[0]?.frontmatter.category;
  const description = `Most recent blog posts in the ${categoryName} category.`;

  return (
    <div className="relative bg-gray-50 pt-16 pb-20 px-4 sm:px-6 lg:pt-24 lg:pb-28 lg:px-8">
      <div className="absolute inset-0">
        <div className="bg-white h-1/3 sm:h-2/3" />
      </div>
      <div className="relative max-w-7xl mx-auto">
        <div className="text-center">
          <h1 className="text-4xl leading-10 font-extrabold tracking-tight text-gray-900 text-center sm:text-5xl sm:leading-none lg:text-6xl">
            {title}
          </h1>
          <p className="mt-3 max-w-3xl mx-auto text-xl text-gray-500 sm:mt-4">
            {description}
          </p>
        </div>
        <div className="my-8">
          <Breadcrumbs pages={pages} />
        </div>
        <div className="mt-12 max-w-lg mx-auto grid gap-5 lg:grid-cols-3 lg:max-w-none">
          {posts.map((post) => (
            <div
              key={post.frontmatter.title}
              className="flex flex-col rounded-lg shadow-lg overflow-hidden"
            >
              <div className="flex-1 bg-white p-6 flex flex-col justify-between">
                <div className="flex items-center gap-x-4 text-xs">
                  <time
                    dateTime={post.frontmatter.date}
                    className="text-gray-500 dark:text-gray-400"
                  >
                    {dateFormat(
                      new Date(post.frontmatter.date),
                      "mmmm dS, yyyy"
                    )}
                  </time>
                  <Link
                    href={`/blog/categories/${post.frontmatter.category.toLowerCase().replace(/\s+/g, "-")}`}
                    className="inline-flex items-center rounded-full bg-yellow-50 px-2 py-1 text-xs font-medium text-yellow-800 ring-1 ring-inset ring-yellow-600/20 dark:bg-yellow-400/10 dark:text-yellow-500 dark:ring-yellow-400/20"
                  >
                    {post.frontmatter.category}
                  </Link>
                </div>
                <div className="flex-1">
                  <Link href={`/blog/${post.slug}`} className="block mt-2">
                    <p className="text-xl font-semibold text-blue-700">
                      {post.frontmatter.title}
                    </p>
                    <p className="mt-3 text-base text-gray-500">
                      {post.frontmatter.description ??
                        `${post.markdownBody}...`}
                    </p>
                  </Link>
                </div>
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

Here is an example from this website: https://www.digitalflask.com/blog/categories/lightning-web-components.

Next.js Tagging Implementation

Once you have implemented the categories, implementing the tags becomes relatively straightforward because it uses the same approach, except they are arrays of keywords.

Create two files: pages/blog/tags/index.js and pages/blog/tags/[tag].js.

Again, we use the first file to render the list of all available tags.

export async function getStaticProps() {
  const tags = ((context) => {
    const keys = context.keys();
    const values = keys.map(context);
    const tags = {};

    for (let i = 0; i < keys.length; i++) {
      const value = values[i];
      const document = matter(value.default);
      for (const tag of document.data?.tags ?? []) {
        const slug = tag.toLowerCase().replace(/\s+/g, "-");
        if (!tags[slug]) {
          tags[slug] = {
            count: 0,
            name: tag,
            slug,
          };
        }
        tags[slug].count++;
      }
    }

    return tags;
  })(require.context("../../../posts", true, /\.md$/));

  return {
    props: {
      tags,
    },
  };
}

Similar to categories, we obtain a list of items that we can loop through to render the links with their labels and the number of posts.

For the dynamic tag page, we also need to fetch all the posts, parse their tags, and generate the list of paths.

export async function getStaticPaths() {
  const categoriesSlugs = ((context) => {
    const keys = context.keys();
    const values = keys.map(context);
    return [...new Set(keys.flatMap((\_, index) =>
      matter(values[index].default).data.tags?.map(t => t.toLowerCase().replace(/\s+/g, '-')) || []
    ))];
  })(require.context("../../../posts", true, /\.md$/));

  const paths = categoriesSlugs.map((slug) => `/blog/tags/${slug}`);

  return {
    paths,
    fallback: false,
  };
}

Additionally, I also receive the list of tagged posts.

export async function getStaticProps({ ...ctx }) {
  const { tag } = ctx.params;
  const posts = ((context) => {
    const keys = context.keys();
    const values = keys.map(context);
    return keys
      .map((key, index) => {
        const slug = key.replace(/^.\*[\\\/]/, "").slice(0, -3);
        const value = values[index];
        const document = matter(value.default);
        return {
          frontmatter: document.data,
          markdownBody: document.content.substring(0, 160),
          slug,
        };
      })
      .filter((post) =>
        post.frontmatter?.tags
          ?.map((t) => t.toLowerCase().replace(/\s+/g, "-"))
          .includes(tag)
      )
      .sort(
        (a, b) => new Date(b.frontmatter.date) - new Date(a.frontmatter.date)
      );
  })(require.context("../../../posts", true, /\.md$/));

  return {
    props: {
      posts,
      tag,
    },
  };
}

Voilá! Now, we have the category and tag system in place, and we want to reference those in places on the website where end-users will find them most beneficial.

For example, I included the category links to each post thumbnail.

Screenshot of the sample category page from this website.

Additionally, I've added the post category and its tags to the post page component in the sidebar. You can also see it on the right sidebar of the current article.

Screenshot of the sample post page from this website with a taxonomy.

I've also used different colour options for categories and tags so that users can distinguish them.

Conclusion

In this post, I described why we want to implement the category and tag system for the blogs.

Touched on the JAMStack and static website generation, what tech stack I use for this website, and how I implemented the categorization and tagging for my blog.

The topic is relatively advanced. I assumed that if you read it, you're already familiar with React and Next.js, so I haven't provided exact instructions.

Instead, I've focused on the getStaticProps and getStaticPaths functions to fetch the relevant data.

It's the most crucial part, as well as the set of NPM packages required for the implementation.

I value good user experience and performance myself, and having structured and organized content on blogs supports that.

I don't have a large blog by any means, but if you have a lot of posts, organizing them will help you further improve your website.

For instance, you could render related articles based on the categories on the post layouts and support search capabilities.

I plan to post more on the topic as I'm now planning to integrate search capabilities into the Next.js website and have implemented custom Meta and Schema.org components. I hope someone will also benefit from it.

Nikita Verkhoshintcev photo

Nikita Verkhoshintcev

Senior Salesforce Technical Architect & Developer

I'm a senior Salesforce technical architect and developer, specializing in Experience Cloud, managed packages, and custom implementations with AWS and Heroku. I have extensive front-end engineering experience and have worked as an independent contractor since 2016. My goal is to build highly interactive, efficient, and reliable systems within the Salesforce platform. Typically, companies contact me when a complex implementation is required. I'm always open to collaboration, so please don't hesitate to reach out!

Let's work together!

Do you have a challenge or goal you'd like to discuss? We offer a free strategy call. No strings attached, just a way to get to know each other.

Book a free strategy call

Stay updated

Subscribe to our newsletter to get Salesforce tips to your inbox.

No spam, we promise!