Code Talk: Adding Tagging Functionality to My Blog



I wanted to add a tagging functionality into my blog so that navigating to different topics would be much easier. For educational purposes, I want to blog about how it was done.
1. Defining The Tag Data Structure
First, I needed to decide how to store tags for each post. I updated the Post
type (essentially a blueprint for an object for those unfamiliar with JS/TS) to include an optional array of strings for tags:
// filepath: src/interfaces/post.ts
export type Post = {
slug: string;
title: string;
date: string;
coverImage: string;
author: Author;
excerpt: string;
ogImage: {
url: string;
};
content: string;
preview?: boolean;
draft: boolean;
tags?: string[];
};
2. Adding Tags to Markdown Content
With the data structure updated, the next step was to add the tags to the actual blog posts which are written in markdown. In the frontmatter of each .md
file, I added a tags
array. For example, for this post:
---
title: "Code Talk: Adding Tagging Functionality to My Blog"
excerpt: "..."
coverImage: "/assets/blog/adding-tagging-functionality-to-my-blog/cover.webp"
date: "2025-05-28T21:45:00Z"
author:
name: Mal Nushi
picture: "/assets/blog/authors/mn.jpeg"
ogImage:
url: "/assets/blog/adding-tagging-functionality-to-my-blog/cover.webp"
draft: false
tags: ["tech", "programming"]
---
3. Updating the API to Handle Tags
The existing API functions in api.ts
needed to be aware of these new tags.
Fetching Tags for a Single Post: The getPostBySlug
function was updated to ensure tags
are parsed from the frontmatter. If no tags are present, it defaults to an empty array.
// filepath: src/lib/api.ts
export function getPostBySlug(slug: string) {
const realSlug = slug.replace(/\.md$/, "");
const fullPath = join(postsDirectory, `${realSlug}.md`);
const fileContents = fs.readFileSync(fullPath, "utf8");
const { data, content } = matter(fileContents);
return { ...data, slug: realSlug, content, tags: data.tags || [] } as Post;
}
Getting All Unique Tags: I added a new function, getAllTags
, to collect all unique tags from all posts. This is useful for creating a page that lists all available tags.
// filepath: src/lib/api.ts
export function getAllTags(): string[] {
const posts = getAllPosts();
const allTags = new Set<string>();
posts.forEach(post => {
if (post.tags) {
post.tags.forEach(tag => allTags.add(tag));
}
});
return Array.from(allTags).sort();
}
Getting Posts by a Specific Tag: Another new function was created, getPostsByTag
, to filter posts that include a specific tag. This is crucial for the individual tag pages.
// filepath: src/lib/api.ts
export function getPostsByTag(tag: string): Post[] {
const lowercaseTag = tag.toLowerCase();
return getAllPosts().filter(post =>
post.tags?.some(t => t.toLowerCase() == lowercaseTag)
);
}
4. Displaying the Tags on Individual Post Pages
To show tags on each blog post, I modified src/app/posts/[slug]/page.tsx
. After fetching the post data, I mapped over the post.tags
array and rendered each tag as a link to its respective tag page:
// filepath: src/app/posts/[slug]/page.tsx
// ...more code...
<PostBody content={content} />
{post.tags && post.tags.length > 0 && (
<div className="max-w-2xl mx-auto mt-8">
<h3 className="text-lg font-semibold mb-2">Tags:</h3>
<div className="flex flex-wrap gap-2">
{post.tags.map((tag) => (
<Link
key={tag}
href={`/tags/${encodeURIComponent(tag.toLowerCase())}`}
className="bg-sky-200 dark:bg-sky-800 text-sky-600 dark:text-sky-300 px-3 py-1 rounded-full text-sm hover:bg-sky-300 dark:hover:bg-sky-700"
>
{tag}
</Link>
))}
</div>
</div>
)}
</article>
// ...more code...
5. Creating Dynamic Pages for Each Tag
I implemented a dynamic route, src/app/tags/[tag]/page.tsx
, to display all posts associated with a specific tag.
Generating Static Paths: The generateStaticParams
function uses getAllTags
to pre-render a page for each unique tag at build time.
// filepath: src/app/tags/[tag]/page.tsx
export async function generateStaticParams() {
const tags = getAllTags();
return tags.map((tag) => ({
tag: encodeURIComponent(tag.toLowerCase()),
}));
}
Generating Metadata: The generateMetadata
function dynamically sets the page title based on the current tag.
// filepath: src/app/tags/[tag]/page.tsx
export async function generateMetadata(props: PageProps): Promise<Metadata> {
const resolvedParams = await props.params;
const tagName = decodeURIComponent(resolvedParams.tag);
return {
title: `Posts tagged "${tagName}" | ${SITE_NAME}`,
description: `Blog posts related to ${tagName}.`,
};
}
Displaying Tagged Posts: The TagPage
component fetches the tag from the URL, calls getPostsByTag
, and then maps over the resulting posts, rendering each one using the PostPreview
component.
// filepath: src/app/tags/[tag]/page.tsx
export default async function TagPage(props: PageProps) {
const resolvedParams = await props.params; // Await the promise to get route parameters
const tagName: string = decodeURIComponent(resolvedParams.tag); // Decode the tag name from the URL, as it might be URL-encoded (e.g., "c%23" for "c#")
const posts = getPostsByTag(tagName); // Retrieve all posts that are tagged with the decoded tag name
return (
<main>
<Container>
<Header />
<h1 className="text-5xl md:text-6xl font-bold tracking-tighter leading-tight md:leading-none mb-12 text-center md:text-left">
Posts tagged: <span className="capitalize">{tagName}</span>
</h1>
{/* Check if there are any posts for the given tag */}
{posts.length > 0 ? (
// If posts exist, display them in a grid
<div className="grid grid-cols-1 md:grid-cols-2 md:gap-x-16 lg:gap-x-32 gap-y-20 md:gap-y-32 mb-32">
{/* Map over the posts array and render a PostPreview component for each post */}
{posts.map((post) => (
<PostPreview
key={post.slug} // Unique key for each post, essential for list rendering
title={post.title}
coverImage={post.coverImage}
date={post.date}
author={post.author}
slug={post.slug}
excerpt={post.excerpt}
tags={post.tags}
/>
))}
</div>
) : (
// If no posts are found for the tag, display a message
<p className="text-center text-xl">No posts found for this tag.</p>
)}
</Container>
</main>
);
}
6. Creating an "All Tags" Page
To allow users to browse all available tags, I created page.tsx
. This page fetches all unique tags using getAllTags
and displays them as links to their respective tag pages.
// filepath: src/app/tags/page.tsx
xport default function AllTagsPage() {
// Fetch all unqiue tags from the blog posts
const tags = getAllTags();
return (
<main>
<Container>
<Header />
<h1 className="text-5xl md:text-6xl font-bold tracking-tighter leading-tight md:leading-none mb-12 text-center md:text-left">
All Tags
</h1>
{/* Conditional rendering: if there are tags, display them */}
{tags.length > 0 ? (
<div className="flex flex-wrap gap-4">
{/* Map through the tags and render a link for each */}
{tags.map((tag) => (
<Link
key={tag}
href={`/tags/${encodeURIComponent(tag.toLowerCase())}`}
className="text-lg font-semibold inline-block py-2 px-4 rounded-full text-sky-600 bg-sky-200 dark:bg-sky-800 dark:text-sky-300 hover:bg-sky-300 dark:hover:bg-sky-700"
>
{tag} {/* Display the tag name */}
</Link>
))}
</div>
) : (
<p className="text-center text-xl">No tags found.</p>
)}
</Container>
</main>
);
}
7. Updating UI Components to Display Tags
The PostPreview
component, used on the homepage and tag pages, was updated to accept and display tags, linking each tag to its page:
// filepath: src/app/_components/post-preview.tsx
export function PostPreview({
// ...other props...
tags,
}: Props) {
return (
<div>
{/* ...other post preview elements... */}
<p className="text-lg leading-relaxed mb-4">{excerpt}</p>
{tags && tags.length > 0 && (
<div className="mb-4">
{tags.map((tag) => (
<Link
key={tag}
href={`/tags/${encodeURIComponent(tag.toLowerCase())}`}
className="text-sm font-semibold inline-block py-1 px-2 rounded-full text-sky-600 bg-sky-200 dark:bg-sky-800 dark:text-sky-300 last:mr-0 mr-1 hover:bg-sky-300 dark:hover:bg-sky-700"
>
{tag}
</Link>
))}
</div>
)}
<Avatar name={author.name} picture={author.picture} />
</div>
);
}
Similarily, the HeroPost
component could also be updated I think if I wanted to display tags there.
8. Customizing the Homepage Introduction
For the homepage, I wanted to go a different direction with the presentation. Instead of relying on the global Header
for the "All Tags" link, I integrated it directly into the Intro
component. This places the site title and the "All Tags" link together at the top of the homepage, followed by the site's subtitle.
// filepath: src/app/_components/intro.tsx
import { SITE_NAME } from "@/lib/constants";
import Link from "next/link";
export function Intro() {
return (
<section className="flex flex-col mt-16 mb-16 md:mb-12">
{/* Wrapper for Title and All Tags link */}
<div className="flex flex-col md:flex-row items-center md:justify-between mb-6 md:mb-8">
<h1 className="text-5xl md:text-7xl lg:text-8xl font-bold tracking-tighter leading-tight mb-4 md:mb-0 md:pr-8">
{/* Link the site name to the homepage */}
<Link href="/" className="hover:underline">
{SITE_NAME}
</Link>
</h1>
<nav>
<Link href="/tags" className="text-lg md:text-xl font-semibold hover:underline whitespace-nowrap">
All Tags
</Link>
</nav>
</div>
{/* Subtitle */}
<h4 className="text-center md:text-left text-lg">
A blog by Mal Nushi—<i>where ideas wander from circuits to sentences.</i>
</h4>
</section>
);
}
9. Adding Navigation
Finally, I added a link to the new "All Tags" page in the main site to navigate within the Header
component.
// filepath: src/app/_components/header.tsx
// ...more code...
<nav>
<Link href="/tags" className="text-lg md:text-xl font-semibold hover:underline ml-4">
All Tags
</Link>
</nav>
// ...more code...
And that is how I added a tagging system to the blog! This makes it much easier to find blog posts based on topics.