Category and tag pages can be your strongest internal-link nodes, or your worst thin-page liability. The build code is the same either way. The difference that decides whether Google indexes them is how much real content each page carries.
This guide uses the Astro 6 Content Layer (stable since the March 10, 2026 release). If you are still on Astro 4 or 5, the API in this article will not compile — Astro 6 removed legacy collections, requires Node 22.12+, and moved Zod to astro/zod. Upgrade first.
TL;DR
- Build category pages as full content pages: a 200-300 word intro, the article list, an FAQ, and links to sibling categories. Keep them indexable.
- Build tag pages minimal, and
noindex,followany tag with fewer than ~5 articles. They are navigation, not landing pages. - In Astro 6, every collection needs a
loader. Useglob()for your articles andfile()for a single JSON/YAML file of category metadata. - Entries no longer have
.slug. Useentry.idfor URLs. - After each build, count generated tag pages so a tag explosion can’t quietly bloat your sitemap.
When this pattern is worth building
- You have 30+ articles and need browse pages, not just a chronological feed.
- You want hub pages to rank for short-tail keywords (e.g. “astro seo tips”).
- You expect readers to keep browsing after they land from a Google entry page.
- You can write a genuine 200-300 word introduction for each hub. If you can’t, you don’t have enough categories yet — merge them.
Skip all of this if you run a small personal blog where one chronological list does the job. Category pages with four links and no prose are a soft-404 risk, not an SEO win.
What changed in Astro 6
If you last touched Astro’s content layer before 2026, the breaking changes below will bite you:
| Astro 4 / early 5 | Astro 6 (current, June 2026) |
|---|---|
src/content/config.ts | src/content.config.ts |
type: 'content' / type: 'data' | removed — every collection needs a loader |
import { z } from 'astro:content' | import { z } from 'astro/zod' |
entry.slug | entry.id |
Astro.glob() | removed — use glob() loader or import.meta.glob |
| Node 18 / 20 | Node 22.12+ required |
The paginate() helper and the getCollection() filter callback are unchanged.
Step by step
1. Define both collections with loaders
In Astro 6, src/content.config.ts declares an articles collection via the glob() loader and a categories data collection via the file() loader. Note z now comes from astro/zod:
import { defineCollection } from 'astro:content';
import { glob, file } from 'astro/loaders';
import { z } from 'astro/zod';
const articles = defineCollection({
loader: glob({ pattern: '**/*.{md,mdx}', base: './src/content/articles' }),
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.coerce.date(),
draft: z.boolean().default(false),
}),
});
const categories = defineCollection({
loader: file('src/content/categories.json'),
schema: z.object({
id: z.string(), // the file() loader keys each record by id
title: z.string(),
intro: z.string().min(200), // schema forces a real intro, not one line
faq: z.array(z.object({ q: z.string(), a: z.string() })).default([]),
}),
});
export const collections = { articles, categories };
The min(200) on intro is the single most useful line here: it makes a thin category page fail the build instead of shipping silently.
2. Build the paginated category index
Create src/pages/[category]/[...page].astro. The [...page] rest param lets paginate() emit /indie-dev/ for page 1 and /indie-dev/2/ onward. Remember: entries are keyed by .id, not .slug.
---
import type { GetStaticPaths } from 'astro';
import { getCollection } from 'astro:content';
export const getStaticPaths = (async ({ paginate }) => {
const published = await getCollection('articles', ({ data }) => !data.draft);
const categories = await getCollection('categories');
return categories.flatMap((cat) => {
const list = published
.filter((a) => a.data.category === cat.id)
.sort((a, b) => +b.data.publishedAt - +a.data.publishedAt);
return paginate(list, {
params: { category: cat.id },
props: { meta: cat.data },
pageSize: 24,
});
});
}) satisfies GetStaticPaths;
const { page, meta } = Astro.props;
const canonical = page.currentPage === 1
? `https://yourdomain.com/${meta.id}/`
: `https://yourdomain.com/${meta.id}/${page.currentPage}/`;
---
<html lang="en">
<head>
<title>{meta.title}</title>
<link rel="canonical" href={canonical} />
</head>
<body>
<h1>{meta.title}</h1>
<p>{meta.intro}</p>
<ul>
{page.data.map((a) => (
<li><a href={`/articles/${a.id}/`}>{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>
The self-canonical on every paginated page is what keeps /indie-dev/2/ from competing with /indie-dev/ in search. Do not point page 2’s canonical back at page 1 — Google treats that as a misconfiguration and may drop the deeper articles from its index.
3. Write a real intro per category
src/content/categories.json is the source of truth. Each record needs a 200+ word intro (your schema enforces it) and the FAQ that renders on the page:
[
{
"id": "indie-dev",
"title": "Indie Dev — building, shipping, and 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. A half-translated site hurts more than a clean single-language one." }
]
}
]
4. Keep tag pages minimal and gate indexing on count
Tag pages live at src/pages/tags/[tag].astro. Set robots dynamically so thin tags drop out of the index without you tracking them by hand:
---
import { getCollection } from 'astro:content';
export const getStaticPaths = async () => {
const articles = await getCollection('articles', ({ data }) => !data.draft);
const tags = [...new Set(articles.flatMap((a) => a.data.tags))];
return tags.map((tag) => ({
params: { tag },
props: { count: articles.filter((a) => a.data.tags.includes(tag)).length },
}));
};
const { tag, count } = { tag: Astro.params.tag, ...Astro.props };
const noindex = count < 5;
---
<head>
{noindex && <meta name="robots" content="noindex,follow" />}
<link rel="canonical" href={`https://yourdomain.com/tags/${tag}/`} />
</head>
noindex,follow is deliberate: Google won’t index the page itself, but it still crawls the outbound links so the articles underneath keep their link equity.
5. Render the page in a section order Google rewards
Intro → article list → FAQ → related categories. Each block adds text and structure a crawler can read. The FAQ block is also where you wire up FAQPage JSON-LD if your layout emits it — that is what produces rich-result snippets.
6. Catch tag explosion after every build
npm run build
find dist/tags -name 'index.html' | wc -l
# More than ~200 tag pages means tags have escaped their leash.
If that number climbs past a couple hundred, your tagging is too granular. Tighten the vocabulary to a controlled list rather than free-typing a tag per article.
Common pitfalls
- Shipping a category page that is just a list with no intro. Google flags these as thin. The
min(200)schema rule exists to stop this at build time. - Auto-generating a tag for every keyword in every article. You end up with hundreds of near-empty pages diluting crawl budget.
- Forgetting pagination. A single category page rendering 200 articles is slow and tanks Core Web Vitals (LCP and CLS).
- Pointing paginated canonicals back at page 1. This buries your deeper articles. Each page self-canonicals.
- Still calling
entry.slug. It isundefinedin Astro 6 — your links will silently render/articles/undefined/. Useentry.id. - Hardcoding category names in URLs. Rename a category and every URL breaks. Drive URLs from the
idfield so a rename is one JSON edit plus a redirect.
FAQ
Should I noindex tag pages? By default, yes — for any tag with fewer than ~5 articles. Empty or near-empty tag pages are pure SEO downside. A tag that accumulates real depth and gets its own intro can graduate to an indexable hub later.
Can a category page rank for short-tail keywords? Yes, but only with a genuine 200+ word introduction and strong internal links from the articles back up to the category. The category page has to read like a curated overview, not a directory listing.
How do I paginate a long category?
Return paginate(list, { pageSize: 24 }) from getStaticPaths, cap each page at 20-30 entries, and use a [...page] rest route so page 1 has a clean URL. Add prev/next links and a self-canonical on every page.
Tags vs categories — what’s the rule? Categories are stable, few (under ten for most sites), and define your site structure. Tags are fluid, many, and only earn their keep when readers genuinely need a cross-cut view. If a tag never gets browsed, delete it.
Do I have to migrate to Astro 6 to use this?
The data-layer code here (glob(), file(), entry.id, astro/zod) is Astro 6 only. On Astro 5 the loaders exist but entry.id and config locations differ slightly; on Astro 4 the whole type: 'content' model is different. See the official upgrade-to-v6 guide before porting.
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