Building Category and Tag Pages in Astro

How to build category and tag index pages in Astro that scale, rank, and avoid the thin-page trap, using Content Collections for the data layer.

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

  1. Extend your articles schema with category and tags in src/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 };
  1. Build the category index at src/pages/[category]/index.astro with getStaticPaths:
---
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>
  1. 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.
  1. Tag pages go at src/pages/tags/[tag].astro but kept minimal. Generate a robots meta 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>
  1. Render intro → article list → FAQ → related categories. Each section adds substance Google can see.

  2. 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
  1. 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 paginate from 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.

Tags: #Indie dev #Astro #Content Collections #SEO #Pillar / Cluster