分类与标签页可以是站内链接最强的节点,也可以是最大的薄页负担。两种情况下,构建代码完全一样。真正决定 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 / 早期 5 | Astro 6(当前,2026 年 6 月) |
|---|---|
src/content/config.ts | src/content.config.ts |
type: 'content' / type: 'data' | 已移除——每个 collection 都要 loader |
import { z } from 'astro:content' | import { z } from 'astro/zod' |
entry.slug | entry.id |
Astro.glob() | 已移除——改用 glob() 加载器或 import.meta.glob |
| Node 18 / 20 | 要求 Node 22.12+ |
paginate() 辅助函数和 getCollection() 的过滤回调没变。
实操步骤
1. 给两个 collection 都配上加载器
Astro 6 里,src/content.config.ts 用 glob() 声明 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.id、astro/zod)只在 Astro 6 上有效。Astro 5 上这些加载器存在,但 entry.id 和配置文件位置略有不同;Astro 4 的整套 type: 'content' 模型则完全不一样。移植前请先看官方的 v6 升级指南。
相关阅读
标签: #独立开发 #Astro #Content Collections #SEO #Pillar / Cluster