Astro 6 静态站怎么做分类与标签页

用 Astro 6 的 Content Layer(glob + file 加载器)做分类与标签页:可扩展、能排名、还能绕开薄页陷阱的实战做法。

分类与标签页可以是站内链接最强的节点,也可以是最大的薄页负担。两种情况下,构建代码完全一样。真正决定 Google 收不收录它们的,是每个页面承载了多少真内容。

本文基于 Astro 6 的 Content Layer(2026 年 3 月 10 日正式版起稳定)。如果你还在用 Astro 4 或 5,文中的 API 编译不过去——Astro 6 移除了旧版 collections、要求 Node 22.12+、并把 Zod 挪到了 astro/zod。先升级再说。

TL;DR

  • 分类页当成完整内容页来做:200-300 字介绍、文章列表、FAQ、再加上指向同级分类的链接。保持可收录。
  • 标签页做精简,文章不到约 5 篇的标签一律 noindex,follow。它是导航,不是落地页。
  • 在 Astro 6 里,每个 collection 都必须有 loader:文章用 glob(),分类元数据用单个 JSON/YAML 文件配 file()
  • 条目不再有 .slug,URL 一律用 entry.id
  • 每次构建后数一下生成的标签页数量,别让标签爆炸悄悄把站点地图撑大。

什么时候值得做这套

  • 已有 30+ 篇,需要浏览页,而不只是一个时间线。
  • 希望 hub 页面能排短尾词(比如 “astro seo tips”)。
  • 预期读者从 Google 入口页进来后还会继续浏览。
  • 每个 hub 你能写出真正的 200-300 字介绍。写不出来,说明分类还不够多——合并它们。

如果你只是个小博客、一个时间线列表就够,那这套全部跳过。只放四个链接、没有正文的分类页是 soft 404 的风险,不是 SEO 收益。

Astro 6 改了什么

如果你上次碰 Astro 内容层还在 2026 年之前,下面这些破坏性变更会咬你一口:

Astro 4 / 早期 5Astro 6(当前,2026 年 6 月)
src/content/config.tssrc/content.config.ts
type: 'content' / type: 'data'已移除——每个 collection 都要 loader
import { z } from 'astro:content'import { z } from 'astro/zod'
entry.slugentry.id
Astro.glob()已移除——改用 glob() 加载器或 import.meta.glob
Node 18 / 20要求 Node 22.12+

paginate() 辅助函数和 getCollection() 的过滤回调没变。

实操步骤

1. 给两个 collection 都配上加载器

Astro 6 里,src/content.config.tsglob() 声明 articles、用 file() 声明 categories 数据集。注意 z 现在从 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(),               // file() 加载器按 id 给每条记录建索引
    title: z.string(),
    intro: z.string().min(200),   // schema 强制写真介绍,不许一行了事
    faq: z.array(z.object({ q: z.string(), a: z.string() })).default([]),
  }),
});

export const collections = { articles, categories };

这里最有用的一行就是 intro 上的 min(200):它让薄分类页在构建阶段直接报错,而不是悄无声息地上线。

2. 做分页的分类索引页

新建 src/pages/[category]/[...page].astro[...page] 这个剩余参数能让 paginate() 把第一页输出成 /indie-dev/、第二页起是 /indie-dev/2/。记住:条目按 .id 建索引,不是 .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="zh">
  <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}>上一页</a>}
    {page.url.next && <a href={page.url.next}>下一页</a>}
  </body>
</html>

每个分页页面都自指 canonical,正是这一点让 /indie-dev/2/ 不会和 /indie-dev/ 抢排名。别把第二页的 canonical 指回第一页——Google 会判定为配置错误,可能把深层文章从索引里丢掉。

3. 每个分类写真介绍

src/content/categories.json 是唯一真源。每条记录都要有 200 字以上的介绍(schema 帮你强制),以及页面上渲染的 FAQ:

[
  {
    "id": "indie-dev",
    "title": "Indie Dev — 一个人做、上线、做收入",
    "intro": "写给独立开发者的实战文章,覆盖大部分教程跳过的部分:域名决策、托管平台权衡、App Store 提审的坑、变现,以及对月访问量在 1 万以下的站点真正有效的 SEO 工作……",
    "faq": [
      { "q": "第一个站要做双语吗?", "a": "只在你能长期维护两种语言的前提下。半翻译的站比一个干净的单语站伤害更大。" }
    ]
  }
]

4. 标签页做精简,按数量决定是否收录

标签页放在 src/pages/tags/[tag].astro。动态设置 robots,让薄标签自动掉出索引,不用你手动盯:

---
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 是有意为之:Google 不收录页面本身,但仍会爬出站链接,这样底下的文章不丢链接权重。

5. 按 Google 看重的区块顺序渲染

介绍 → 文章列表 → FAQ → 相关分类。每一块都给爬虫加上能读到的文字和结构。FAQ 区块也是接 FAQPage JSON-LD 的地方(如果你的布局会输出的话)——这正是出富结果摘要的来源。

6. 每次构建后抓标签爆炸

npm run build
find dist/tags -name 'index.html' | wc -l
# 超过约 200 个标签页,说明标签已经脱缰了。

如果这个数冲过两三百,说明你的打标签太碎。把词表收紧成一份受控清单,别每篇文章随手新建标签。

容易踩的坑

  • 分类页只放列表、没介绍。 Google 直接当薄页。min(200) 这条 schema 规则就是为了在构建阶段拦住它。
  • 从每篇文章的每个关键词自动生成标签。 最后几百个近乎空白的页面,稀释抓取预算。
  • 忘做分页。 一个分类一页渲染 200 篇,又慢又拖垮 Core Web Vitals(LCP 和 CLS)。
  • 把分页 canonical 指回第一页。 这会把深层文章埋掉。每页都要自指 canonical。
  • 还在调 entry.slug 在 Astro 6 里它是 undefined,你的链接会悄悄渲染成 /articles/undefined/。改用 entry.id
  • 把分类名硬编码进 URL。 改个分类名,所有 URL 全断。让 URL 由 id 字段驱动,改名就只是改一处 JSON 加一条重定向。

FAQ

标签页要 noindex 吗? 默认要——对任何文章不到约 5 篇的标签都加。空的或近乎空的标签页是纯 SEO 负担。某个标签积累出真深度、也写了自己的介绍后,可以晚点再升级成可收录的 hub。

分类页能排短尾词吗? 能,但前提是有真正的 200 字以上介绍,并且文章要有强内链反向指回分类。分类页要读起来像一篇精选综述,而不是目录列表。

长分类怎么分页?getStaticPaths 里返回 paginate(list, { pageSize: 24 }),每页控制在 20-30 条,并用 [...page] 剩余路由让第一页有干净 URL。每页都加 prev/next 链接和自指 canonical。

标签和分类的区别是什么? 分类稳定、数量少(多数站点不超过十个),定义站点结构;标签流动、数量多,只有读者真正需要横切视角时才有价值。一个从没被点开浏览的标签,就删掉。

一定要迁到 Astro 6 才能用这套吗? 本文的数据层代码(glob()file()entry.idastro/zod)只在 Astro 6 上有效。Astro 5 上这些加载器存在,但 entry.id 和配置文件位置略有不同;Astro 4 的整套 type: 'content' 模型则完全不一样。移植前请先看官方的 v6 升级指南

相关阅读

标签: #独立开发 #Astro #Content Collections #SEO #Pillar / Cluster