Category and tag pages can be your strongest internal-link nodes, or your worst thin-page liability. The pattern is the same; the difference is in how much content you put on them.
Background
Astro makes category and tag pages easy via getStaticPaths and content collections. The technical part takes an afternoon. The strategic part — making sure these pages have enough real content to rank — is what trips most indie sites. A category page with a list of 4 articles and no description is a soft 404 waiting to happen.
How to tell
- You have 30+ articles and need browse pages for organization.
- You want hub pages to rank for short-tail keywords.
- You expect readers to discover content beyond Google entry pages.
- You can write a 200-300 word introduction for each hub.
Quick verdict
Build category pages as full content pages with real introductions, lists, and FAQs. Build tag pages minimal and consider noindex if they stay thin.
Step by step
- Extend your articles schema with
categoryandtagsinsrc/content/config.ts:
import { defineCollection, z } from 'astro:content';
const articles = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string().min(120).max(160),
category: z.enum(['indie-dev', 'ai-tools', 'prompt-library']),
tags: z.array(z.string()).default([]),
publishedAt: z.date(),
draft: z.boolean().default(false),
}),
});
const categories = defineCollection({
type: 'data',
schema: z.object({
slug: z.string(),
title: z.string(),
intro: z.string().min(200), // forces real intro, not 1 line
faq: z.array(z.object({ q: z.string(), a: z.string() })).default([]),
}),
});
export const collections = { articles, categories };
- Build the category index at
src/pages/[category]/index.astrowithgetStaticPaths:
---
import { getCollection, getEntry } from 'astro:content';
import { paginate } from 'astro:paginate';
export async function getStaticPaths({ paginate }) {
const allArticles = await getCollection('articles', e => !e.data.draft);
const categories = await getCollection('categories');
return categories.flatMap(cat => {
const list = allArticles.filter(a => a.data.category === cat.data.slug);
return paginate(list, {
params: { category: cat.data.slug },
props: { meta: cat.data },
pageSize: 24,
});
});
}
const { page, meta } = Astro.props;
---
<html>
<head>
<title>{meta.title}</title>
<link rel="canonical" href={`https://yourdomain.com/${meta.slug}/`} />
</head>
<body>
<h1>{meta.title}</h1>
<p>{meta.intro}</p>
<ul>
{page.data.map(a => (
<li><a href={`/articles/${a.slug}/`}>{a.data.title}</a></li>
))}
</ul>
{page.url.prev && <a href={page.url.prev}>Previous</a>}
{page.url.next && <a href={page.url.next}>Next</a>}
</body>
</html>
- Write a 200-300 word intro per category as a JSON/YAML file in
src/content/categories/:
# src/content/categories/indie-dev.yaml
slug: indie-dev
title: Indie Dev — building, shipping, monetizing solo
intro: >
Practical articles for solo developers shipping content sites and
small apps. Covers the parts most tutorials skip: domain decisions,
hosting trade-offs, App Store submission gotchas, monetization,
and the SEO work that actually moves the needle for sites under
10,000 monthly visitors. ...
faq:
- q: Should I start with a bilingual site?
a: Only if you can maintain both languages forever.
- Tag pages go at
src/pages/tags/[tag].astrobut kept minimal. Generate arobotsmeta based on count:
---
const articlesWithTag = (await getCollection('articles'))
.filter(a => a.data.tags.includes(tag));
const shouldNoindex = articlesWithTag.length < 5;
---
<head>
{shouldNoindex && <meta name="robots" content="noindex,follow" />}
<link rel="canonical" href={`https://yourdomain.com/tags/${tag}/`} />
</head>
-
Render intro → article list → FAQ → related categories. Each section adds substance Google can see.
-
After build, count generated pages to catch tag explosion:
npm run build
find dist/tags -name 'index.html' | wc -l
# if this is > 200, you have a tag-explosion problem
- Decide your noindex policy: keep categories indexable; noindex any tag page with fewer than 5 articles.
Common pitfalls
- Shipping category pages with just a list and no introduction — Google flags these as thin.
- Auto-generating tag pages from every word in articles, ending up with hundreds of near-empty pages.
- Forgetting pagination — a single category page with 200 articles is slow and hurts CWV.
- Letting the same content appear under multiple categories without canonical handling.
- Hardcoding category names in URLs — when you rename a category, every URL breaks.
Who this is for
Astro content sites with 30+ articles aiming for category-level SEO and reader retention.
When to skip this
Small personal blogs where a single chronological list is fine.
FAQ
- Should I noindex tag pages?: Yes by default in 2026 unless you write real content on them. Empty tag pages are pure SEO downside.
- Can a category page rank for short-tail keywords?: Yes, but only with real introductory content and strong internal links from articles back up to the category.
- How do I paginate a long category?: Use
paginatefrom Astro’s pagination API; cap per-page at 20-30 entries and add prev/next links plus a self-canonical on each page. - Tags vs categories — what’s the rule?: Categories are stable, few, and define site structure. Tags are fluid, many, and only useful when readers need cross-cuts.
Related
- Building a Markdown / MDX content site that scales
- Astro SEO basics: title, meta, canonical, hreflang
- Astro Content Collections — a 30-minute getting-started
Tags: #Indie dev #Astro #Content Collections #SEO #Pillar / Cluster