How to add a tag system with index and per-tag pages in Astro
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 }:
// 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:
---
// 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.
---
// 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 tagastrobecomes the page/tags/astro. propsis 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:
<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
getAllTagscounts tags with aMap, then sorts by count with an alphabetical tiebreaker.- The
/tagsindex is justgetAllTagsmapped to pills — server-rendered, no client JS. getStaticPathsin[tag].astrogenerates one static page per tag;paramsfills the URL,propscarries 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.