Adding Category and Tag Index Pages to a Next.js Blog
Table of Contents
The Problem
This blog had individual category pages (/en/category/programming) and tag pages (/en/tag/nextjs), but no way to discover what categories and tags exist. You could reach them via badges on posts, but there was no overview — no "what does this blog cover?" page.
I wanted to add category and tag index pages for both locales.
Zero New Data-Layer Code
What made this implementation satisfying was that every function needed already existed in src/lib/posts.ts:
getAllCategories()— returns all category objectsgetCategoryName()/getCategoryDescription()— bilingual name and descriptiongetPostsByCategory()— posts by category (for counts)getAllTags(locale)— all tags for a localegetPostsByTag()— posts by tag (for counts)
No new utilities, no new components, no new data fetching patterns. Just composing existing functions into new pages. This is the payoff of building a well-factored data layer upfront.
Card Grid vs. Tag Cloud: Why Different Layouts
The two index pages use different layouts, and the reason comes down to the shape of the data.
Categories use a card grid (grid gap-4 sm:grid-cols-2). Each category has a name, description, and post count — structured data that fits naturally into a card. There are few categories (currently just one), so each deserves visual weight:
<Link className="rounded-lg border border-border p-4 transition-colors hover:bg-accent">
<h2 className="font-semibold">{name}</h2>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<p className="mt-2 text-xs text-muted-foreground">{postCount} posts</p>
</Link>Tags use a flex-wrap tag cloud. Tags are flat strings with no description — just a name and count. There are many of them, so a dense layout makes more sense:
<Link className="inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1.5 text-sm">
#{tag} <span className="text-xs">({postCount})</span>
</Link>The tag cloud badges are deliberately larger than the existing TagBadge component (px-3 py-1.5 text-sm vs px-2.5 py-0.5 text-xs) for better readability and tap targets on the index page.
The Locale Asymmetry
One subtle design difference: getAllTags(locale) takes a locale argument, but getAllCategories() doesn't. This reflects how the two are defined:
- Categories are static YAML files — the same categories exist regardless of locale, with bilingual
name/nameJaanddescription/descriptionJafields - Tags are free-form strings in post frontmatter — Japanese and English posts may have completely different tag sets
This means the tag index can show different content per locale, while the category index shows the same categories with translated names. It's a subtle but important distinction when building bilingual content systems.
Summary
- Existing functions compose well — the fact that zero new data-layer code was needed validates the helper function design in
posts.ts. When your abstractions are right, new features are just composition. - Let data shape drive layout — categories (structured, few) → card grid; tags (flat, many) → tag cloud. Don't force the same layout on fundamentally different data.
- Static vs. dynamic metadata has design implications — YAML-defined categories are locale-independent; frontmatter tags are locale-dependent. This asymmetry matters for bilingual index pages.
