
How to add tags and category pages to an Astro blog?
To add tags and category pages to an Astro blog, store them in each post's frontmatter, declare them in your collection schema (for example tags: z.array(z.string()).default([]) and category: z.enum([...])), and create dynamic routes such as src/pages/tags/[tag].astro. In that route's getStaticPaths(), load your posts with getCollection(), collect every unique tag with a Set, and return one path per tag with the matching posts passed as props. Astro then builds a static page for each tag at build time. Category pages work the same way, and Astro's paginate() helper splits long archives into numbered pages.
Taxonomy pages look simple, but small decisions matter: how tags are slugified, whether drafts leak through, how pages are sorted, and whether archives get too long. In this article you'll learn how to model tags and categories in your schema, build tag pages and a tag index with post counts, create paginated category pages, generate clean URL slugs, link to taxonomy pages from posts, and avoid common mistakes. Every example was built and type-checked with Astro 7.4.
Tags vs Categories
The two are often used interchangeably, but it helps to give them different jobs:
| Categories | Tags | |
|---|---|---|
| How many per post | Usually one | Several |
| Who defines them | You, as a fixed list | Authors, as needed |
| Purpose | Main sections of the blog | Cross-cutting topics |
| Schema type | z.enum([...]) | z.array(z.string()) |
| Example | guides, news, opinion | astro, css, performance |
A fixed category list is a good fit for an enum, because a typo becomes a build error. Tags are free-form, so a string array with a transform for consistency works better.
Step 1: Add Them to the Schema
Start with the collection definition:
// src/content.config.ts
import { defineCollection } from "astro:content";
import { glob } from "astro/loaders";
import { z } from "astro/zod";
const blog = defineCollection({
loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
category: z.enum(["guides", "news", "opinion"]),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
Two choices here save you trouble later:
- tags defaults to []: every post has an array, so
post.data.tags.includes(tag)never throws on a post without tags. - category is an enum: a post with
category: tutorialsfails the build withInvalid option: expected one of "guides"|"news"|"opinion".
If you want tags normalised automatically, add .transform((tag) => tag.toLowerCase()) to the inner z.string(). The details of schemas are in how to define a content collection schema with Zod.
A post's frontmatter then looks like this:
---
title: "Content collections in practice"
description: "How I organise Markdown posts with a schema, references and tag pages in Astro."
pubDate: 2026-10-10
category: guides
tags: ["astro", "content"]
---
Step 2: Centralise the Post Query
Every taxonomy page needs the same list of published, sorted posts. Put that query in one place so drafts can't leak through a tag page:
// src/lib/posts.ts
import { getCollection, type CollectionEntry } from "astro:content";
export type Post = CollectionEntry<"blog">;
export async function getPublishedPosts(): Promise<Post[]> {
const posts = await getCollection("blog", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
return posts.sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
}
export function slugifyTag(tag: string): string {
return tag.toLowerCase().trim().replace(/\s+/g, "-");
}
slugifyTag() turns a tag like "Year Review" into year-review, which is safe in a URL. In my test build a draft post had a tag no other post used, and because every page went through getPublishedPosts(), no page was generated for that tag in production. More on this helper pattern in how to query and sort content collections.
Step 3: Build the Tag Pages
Create a dynamic route. The bracketed file name tells Astro that tag is a parameter:
---
// src/pages/tags/[tag].astro
import type { GetStaticPaths } from "astro";
import { getPublishedPosts, slugifyTag } from "../../lib/posts";
export const getStaticPaths = (async () => {
const posts = await getPublishedPosts();
const tags = [...new Set(posts.flatMap((post) => post.data.tags))];
return tags.map((tag) => ({
params: { tag: slugifyTag(tag) },
props: {
tag,
posts: posts.filter((post) => post.data.tags.includes(tag)),
},
}));
}) satisfies GetStaticPaths;
const { tag, posts } = Astro.props;
---
<h1>Posts tagged “{tag}”</h1>
<ul>
{posts.map((post) => (
<li><a href={`/blog/${post.id}/`}>{post.data.title}</a></li>
))}
</ul>
<a href="/tags/">All tags</a>
Here's what happens, step by step:
flatMap()combines every post's tags into one array.new Set()removes duplicates, and the spread turns it back into an array.- For each tag, the route returns a slug for the URL and the original tag name plus its posts as props.
- The posts are already sorted, so each tag page lists newest first.
Writing getStaticPaths as an arrow function with satisfies GetStaticPaths lets TypeScript infer Astro.props from what you return, so tag and posts are typed without a separate Props interface. If dynamic routes are new to you, read dynamic routes with getStaticPaths.
The build generated one page per tag:
├─ /tags/astro/index.html
├─ /tags/content/index.html
├─ /tags/meta/index.html
├─ /tags/year-review/index.html
And /tags/astro/ listed both posts with that tag:
<h1>Posts tagged “astro”</h1><ul><li><a href="/blog/second-post/">Content collections in practice</a></li><li><a href="/blog/first-post/">My first post</a></li></ul>
Slugs and Duplicate Tags
Be careful when two different tag strings produce the same slug, for example "Year Review" and "year review". The Set treats them as different tags, but both map to /tags/year-review/, so getStaticPaths() returns two paths for one URL and each path only carries the posts for its own spelling. The cleanest fix is to normalise tags in the schema with a lowercase transform, so they're already unique by the time pages are built. With that transform in place, a test post tagged "Year Review" and another tagged "year review" shared a single /tags/year-review/ page. Alternatively, deduplicate by slug in getStaticPaths().
Step 4: Build a Tag Index With Counts
A page listing every tag, with the number of posts each, helps readers browse:
---
// src/pages/tags/index.astro
import { getPublishedPosts, slugifyTag } from "../../lib/posts";
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);
}
}
const tags = [...counts].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
---
<h1>Tags</h1>
<ul>
{tags.map(([tag, count]) => (
<li><a href={`/tags/${slugifyTag(tag)}/`}>{tag}</a> ({count})</li>
))}
</ul>
Tags are sorted by count, most-used first, with alphabetical order as a tiebreaker. The output from my test project:
<h1>Tags</h1><ul><li><a href="/tags/astro/">astro</a> (2)</li><li><a href="/tags/content/">content</a> (1)</li><li><a href="/tags/meta/">meta</a> (1)</li></ul>
This page is static (src/pages/tags/index.astro) and lives alongside the dynamic [tag].astro route without conflict, because /tags/ and /tags/astro/ are different URLs. For a tag cloud, scale the font size by count instead of showing a number.
Step 5: Build Paginated Category Pages
Categories often hold far more posts than tags, so paginate them. A rest parameter for the page number gives you /categories/guides/ for page one and /categories/guides/2/ for page two:
---
// src/pages/categories/[category]/[...page].astro
import type { GetStaticPaths } from "astro";
import { getPublishedPosts } from "../../../lib/posts";
export const getStaticPaths = (async ({ paginate }) => {
const posts = await getPublishedPosts();
const categories = [...new Set(posts.map((post) => post.data.category))];
return categories.flatMap((category) =>
paginate(
posts.filter((post) => post.data.category === category),
{ params: { category }, props: { category }, pageSize: 10 },
),
);
}) satisfies GetStaticPaths;
const { page, category } = Astro.props;
---
<h1>{category}</h1>
<ul>
{page.data.map((post) => (
<li><a href={`/blog/${post.id}/`}>{post.data.title}</a></li>
))}
</ul>
<nav>
{page.url.prev && <a href={page.url.prev}>Newer</a>}
{page.url.next && <a href={page.url.next}>Older</a>}
</nav>
<p>Page {page.currentPage} of {page.lastPage}</p>
The key detail is flatMap(). paginate() returns an array of paths for one category, and flatMap() merges those arrays for all categories into the single array getStaticPaths() must return. The params option adds the category to every generated path, and props passes extra values alongside page.
Each page receives a page object with data (this page's posts), currentPage, lastPage and url.prev/url.next. In the test build /categories/guides/ rendered "Page 1 of 1". How to paginate an Astro blog covers the page object fully, including numbered page links.
Since categories here are an enum, you could also loop over the enum values to build pages for every category, even empty ones. Building only categories that have posts, as above, avoids empty archive pages.
Step 6: Link Taxonomy Pages From Posts
Readers find tag pages through links on posts. Show the category and tags on each post page, using the same slug function as the routes:
---
// src/components/PostMeta.astro
import type { CollectionEntry } from "astro:content";
import { slugifyTag } from "../lib/posts";
interface Props {
post: CollectionEntry<"blog">;
}
const { post } = Astro.props;
---
<p>
Filed under <a href={`/categories/${post.data.category}/`}>{post.data.category}</a>
</p>
{post.data.tags.length > 0 && (
<ul class="tags">
{post.data.tags.map((tag) => (
<li><a href={`/tags/${slugifyTag(tag)}/`}>#{tag}</a></li>
))}
</ul>
)}
Using slugifyTag() in both places guarantees the links match the generated URLs. If you build the slug differently in a link and a route, you'll get 404s that only show up for certain tags.
Making Category Names Readable
Enum values like guides are good slugs but plain headings. Keep a lookup of display names and descriptions:
// src/lib/categories.ts
export const categoryInfo = {
guides: { name: "Guides", description: "Step-by-step tutorials." },
news: { name: "News", description: "Releases and announcements." },
opinion: { name: "Opinion", description: "Thoughts and commentary." },
} as const;
Then use categoryInfo[category].name in headings and description in meta tags. Because category is typed as the enum union, TypeScript checks that every category has an entry. If categories grow richer, with images or long descriptions, consider turning them into their own collection and using reference(); see how to reference one collection from another.
SEO Tips for Taxonomy Pages
- Give each page a unique title and description: "Posts tagged astro" alone is thin. Add a short description for important tags.
- Avoid near-empty pages: tags used once create many thin pages. Consider only building pages for tags with two or more posts, or adding
noindexto the rest. - Keep URLs stable: changing how you slugify tags changes every URL. Add redirects if you must.
- Include taxonomy pages in your sitemap: they're good entry points for search engines.
FAQ: Tags and Category Pages in Astro
Create src/pages/tags/[tag].astro and export getStaticPaths(). Inside it, load your posts, collect unique tags with a Set, and return one path per tag with the matching posts as props. Astro builds a static page for each.
Use z.enum() when you have a fixed list of categories. Typos then fail the build, and TypeScript knows every possible value. Use a plain string or a separate collection if categories change often or need extra data.
Convert them to URL-safe slugs, for example by lowercasing and replacing spaces with hyphens, and use the same function in your routes and links. Normalising tags in the schema with a transform also prevents duplicate slugs.
Use a route such as src/pages/categories/[category]/[...page].astro, call paginate() once per category with the category in params, and merge the results with flatMap(). Each page receives a page object with data and navigation URLs.
One of your queries isn't filtering drafts. Use a single helper function for published posts in every taxonomy route, so tag and category pages are built from exactly the same list as the blog index.
No. With getStaticPaths(), tag and category pages are generated at build time as static HTML. Adding a new tag only requires a rebuild.
Conclusion
Tag and category pages in Astro are built from three pieces: schema fields that describe each post's taxonomy, a shared query helper that returns published posts in order, and dynamic routes whose getStaticPaths() turn unique tags and categories into static pages. A Set finds the unique values, a slug function makes them URL-safe, and paginate() with flatMap() keeps category archives manageable.
Keep the slug logic in one function used by both routes and links, normalise tags so different spellings don't collide, and filter drafts in one place. With those habits, adding a tag to a post is all it takes for a new archive page to appear on your next build.


