ehsan.blog
~/blog/how-to-add-a-tag-system-in-astro — zsh
cat how-to-add-a-tag-system-in-astro.md

How to add a tag system with index and per-tag pages in Astro

·5 min read

Tags are how readers browse a blog by topic instead of by date. A good tag system needs three parts: a way to gather every tag with its post count, an index page listing them all, and a dedicated page per tag showing its posts. In a static Astro site, all three are built at compile time from your typed content collection. Here’s the whole thing, grounded in the real code on this blog.

Step 1: collect every tag with a count

The data layer is one helper in src/utils/posts.ts. It walks every published post, tallies each tag, and returns a sorted list of { tag, count }:

ts
// blog/src/utils/posts.ts
/** Unique tags with post counts, sorted by count then name. */
export async function getAllTags(): Promise<{ tag: string; count: number }[]> {
  const posts = await getPublishedPosts()
  const counts = new Map<string, number>()
  for (const post of posts) {
    for (const tag of post.data.tags) {
      counts.set(tag, (counts.get(tag) ?? 0) + 1)
    }
  }
  return [...counts.entries()]
    .map(([tag, count]) => ({ tag, count }))
    .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag))
}

A Map is the natural tool for counting: for each tag on each post, counts.get(tag) ?? 0 reads the running total (defaulting to 0 the first time a tag appears) and adds one. Spreading counts.entries() turns the map into an array of pairs, which becomes an array of { tag, count } objects.

The sort has two levels: b.count - a.count puts the most-used tags first, and when two tags tie, a.tag.localeCompare(b.tag) breaks the tie alphabetically. That || fallback only runs when the count difference is 0. Note it builds on getPublishedPosts, so drafts don’t inflate any counts.

Step 2: the /tags index page

The index page just calls getAllTags and renders a pill for each one. Astro components run their frontmatter script on the server at build time, so this is plain data-fetching — no client JavaScript:

astro
---
// blog/src/pages/tags/index.astro
import { getAllTags } from "@/utils/posts"
import TagPill from "@/components/TagPill.astro"

const tags = await getAllTags()
---

{
  tags.length === 0 ? (
    <p>No tags yet.</p>
  ) : (
    <div class="flex flex-wrap gap-3">
      {tags.map(({ tag, count }) => (
        <TagPill tag={tag} count={count} />
      ))}
    </div>
  )
}

Each TagPill gets both the tag and its count, so the UI can show something like astro 12. The empty-state check (tags.length === 0) is a small touch that keeps the page from rendering a blank void before you’ve written anything.

Step 3: a page for every tag with getStaticPaths

The interesting part is the per-tag page. I want a real URL like /tags/astro for every tag — but I don’t know the tags ahead of time; they come from the content. Astro solves this with getStaticPaths: a function on a dynamic route file ([tag].astro) that returns the full list of pages to generate. The [tag] in the filename is a parameter that each returned path fills in. If the params/props split is new to you, the deep dive on dynamic post routes with getStaticPaths walks through the same mechanism for individual posts.

astro
---
// blog/src/pages/tags/[tag].astro
import type { GetStaticPaths } from "astro"
import { getPublishedPosts, getAllTags } from "@/utils/posts"
import PostCard from "@/components/PostCard.astro"

export const getStaticPaths = (async () => {
  const [posts, tags] = await Promise.all([
    getPublishedPosts(),
    getAllTags(),
  ])
  return tags.map(({ tag }) => ({
    params: { tag },
    props: {
      tag,
      posts: posts.filter((p) => p.data.tags.includes(tag)),
    },
  }))
}) satisfies GetStaticPaths

const { tag, posts } = Astro.props
---

Here’s what’s happening:

  • It fetches all posts and all tags at once with Promise.all.
  • For each tag it returns one route object. params: { tag } fills the [tag] slot in the URL, so the tag astro becomes the page /tags/astro.
  • props is data handed to that specific page. Crucially, the filtering — posts.filter((p) => p.data.tags.includes(tag)) — happens here, at build time, so each page arrives with its posts already computed. No filtering runs in the browser.

Below the frontmatter, the page reads those props and renders a card per post:

astro
<h1><span>#</span>{tag}</h1>
<p>{posts.length} post{posts.length === 1 ? "" : "s"}</p>
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
  {posts.map((post) => <PostCard post={post} />)}
</div>
<a href="/tags">← all tags</a>

The posts.length === 1 ? "" : "s" handles the “1 post” vs “3 posts” pluralization, and the link back to /tags closes the loop between the two pages.

flowchart TD
  A[getAllTags] --> B["/tags index: list every tag"]
  A --> C["getStaticPaths in [tag].astro"]
  C --> D["/tags/astro"]
  C --> E["/tags/typescript"]
  C --> F["/tags/... one page per tag"]

Why build-time generation is the win

Because getStaticPaths runs during the build, Astro emits a real, static HTML file for every tag. Visitors (and search engines) get fully-formed pages instantly, there’s no runtime cost, and adding a new tag to a post automatically creates its page on the next build. You never maintain a list of tags by hand.

What to remember

  • getAllTags counts tags with a Map, then sorts by count with an alphabetical tiebreaker.
  • The /tags index is just getAllTags mapped to pills — server-rendered, no client JS.
  • getStaticPaths in [tag].astro generates one static page per tag; params fills the URL, props carries its posts.
  • Filter each tag’s posts at build time inside getStaticPaths, not in the browser.
  • Everything is derived from the content collection, so tag pages appear and update automatically.

Related reading: the same shared-tag data also powers related-post suggestions and prev/next navigation.

ls ./related
cat ./comments

Comments are not configured yet. Enable GitHub Discussions and paste the giscus repo-id / category-id into src/consts.ts.