SEO 工程化实践笔记
SEO 工程化不是“给页面补一个 title”。它更像一套面向搜索引擎的产品交付系统:让重要页面能被发现、被正确理解、被稳定索引,并且在内容和路由持续增长时仍然可维护。
这份笔记先按工程分层梳理 SEO,再总结新页面接入和 Code Review 时可以复用的检查点。
一句话模型
一个页面想从搜索获得流量,需要依次过关:
- 能被发现:robots、sitemap、内链没有挡住它。
- 能被抓取:服务器能稳定返回页面和关键资源。
- 值得索引:页面有质量门槛,不和参数页、重复页互相打架。
- 权威 URL 清楚:canonical 指向唯一主版本。
- 语言关系清楚:hreflang 告诉搜索引擎各语言版本如何对应。
- 内容可理解:metadata 和 JSON-LD 让页面语义明确。
- 体验过得去:渲染、性能、图片、缓存不会拖垮抓取和转化。
- 上线后能复盘:Search Console / 日志 / 数据分析能支持迭代。
1. Crawling:让搜索引擎发现正确的页面
要解决的问题
搜索引擎每天能抓的 URL 有限。工程上要把抓取预算花在真正有价值的页面上,而不是登录页、接口、参数页、废弃路径和 preview 环境。
实践清单
- 生产环境和非生产环境使用不同 robots 策略。
- 非生产环境默认
disallow: /,避免 preview / staging 被索引。 robots.txt中屏蔽 API、内部路由、登录后页面、无意义参数入口。- 根 sitemap 作为 sitemap index,列出各业务子 sitemap。
- 内容量大的 sitemap 分片,避免单文件过大。
- sitemap 中只放希望被搜索引擎发现的 URL,不把所有内部 URL 都塞进去。
- 对 CMS、UGC、动态内容这类页面,要有稳定的数据源和兜底策略。
值得注意的工程点
真实项目里的 sitemap 通常要注意几个处理:
- 根
sitemap.ts不直接放所有页面,而是列出多个子 sitemap。 - 内容详情 / 主题文章这类大集合使用
generateSitemaps()分片。 SITEMAP_MAX_URLS取 5000,而不是协议上限 50000,让分片更稳。- CMS 抖动返回空结果时不缓存空 sitemap,避免“短暂失败”变成“长时间空索引”。
- robots 会在非生产环境阻止全站抓取。
robots 示例
import type { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
const isProduction = process.env.NODE_ENV === 'production';
if (!isProduction) {
return {
rules: { userAgent: '*', disallow: '/' },
};
}
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/*', '/admin/*', '/account/*', '/search/*'],
},
sitemap: 'https://example.com/sitemap.xml',
};
}sitemap 分片示例
import type { MetadataRoute } from 'next';
const PAGE_SIZE = 5000;
export async function generateSitemaps() {
const total = await getPublicContentCount();
return Array.from({ length: Math.ceil(total / PAGE_SIZE) }, (_, id) => ({ id }));
}
export default async function sitemap({
id,
}: {
id: number;
}): Promise<MetadataRoute.Sitemap> {
const items = await getPublicContentPage({
offset: id * PAGE_SIZE,
limit: PAGE_SIZE,
});
return items.map((item) => ({
url: `https://example.com/articles/${item.slug}`,
lastModified: item.updatedAt,
}));
}2. Indexing:决定哪些页面应该进入索引
要解决的问题
不是所有能访问的页面都应该被索引。动态筛选页、低质量内容页、重复语言页、个人结果页,都可能浪费抓取预算或稀释主页面权重。
实践清单
- 给页面建立明确的索引策略:
index/noindex/follow。 - 对搜索页、筛选页、排序页、分页页谨慎开放索引。
- 对 UGC / CMS 内容建立质量门槛。
- 允许运营或编辑通过 CMS 字段覆盖索引状态。
noindex, follow适合“页面不收录,但页面上的链接仍有价值”的场景。noindex, nofollow适合个人结果、隐私内容、无公共价值页面。- sitemap 与页面 robots 必须一致:不要在 sitemap 里推一个页面自己
noindex的 URL。
值得注意的工程点
内容详情页不是简单地“所有详情都 index”。它综合了三层判断:
- locale 是否在 “可索引语言”白名单中。
- CMS
meta.indexed是否显式打开或关闭。 - 没有 CMS 覆盖时,再走推荐标记 / 访问量等业务质量门槛。
这类策略适合内容库、模板库、UGC 广场。它承认“页面很多”不等于“都值得收录”。
索引策略示例
type IndexDecisionInput = {
locale: string;
editorIndexed?: boolean | null;
isFeatured: boolean;
views: number;
};
const INDEXABLE_LOCALES = new Set(['en-US', 'zh-CN', 'ja-JP']);
export function shouldIndexPage(input: IndexDecisionInput) {
if (!INDEXABLE_LOCALES.has(input.locale)) return false;
if (input.editorIndexed === true) return true;
if (input.editorIndexed === false) return false;
return input.isFeatured || input.views >= 10000;
}在页面 metadata 里使用:
return {
robots: shouldIndex
? { index: true, follow: true }
: { index: false, follow: true },
};3. Canonicalization:给重复内容指定权威 URL
要解决的问题
同一内容经常会有多个 URL:
- 带 query 的筛选 URL
- 同一详情页的旧 slug 和新 slug
- 入口页和探索页
- 默认语言与带 locale prefix 的 URL
- CMS 生成 URL 与前端路由 URL
canonical 的作用是告诉搜索引擎哪个 URL 是主版本。
实践清单
- 每个可索引页面都应该有 canonical。
- canonical 应该是绝对 URL。
- 参数页通常 canonical 到无参数的干净 URL。
- 动态探索页如果只是浏览体验,canonical 回对应入口页。
- 改 slug 时考虑 redirect 与 canonical 的配合。
- canonical helper 要集中维护,不要每个页面手写。
canonical helper 示例
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://example.com';
const DEFAULT_LOCALE = 'en-US';
export function generateCanonical(locale: string, path: string) {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const localizedPath =
locale === DEFAULT_LOCALE ? normalizedPath : `/${locale}${normalizedPath}`;
return new URL(localizedPath, SITE_URL).href;
}Next.js 写法
return {
alternates: {
canonical: generateCanonical(locale, path),
},
};4. International SEO:处理多语言版本
要解决的问题
多语言站点不能只靠 URL 看起来不同。搜索引擎需要知道这些页面是同一内容的不同语言版本,否则容易出现:
- 英文页面抢中文 query。
- 低质量翻译页进入索引。
- 默认语言和 locale prefix 页面重复。
- sitemap 里推了 16 份页面,但页面自身没有 hreflang 对应关系。
实践清单
- 用 hreflang 输出所有语言版本。
- 增加
x-default。 - 默认语言是否带 prefix 要统一。
- sitemap 也要输出语言 alternates。
- 不是所有 locale 都必须参与索引,尤其是机器翻译或内容不足的 locale。
- 页面 metadata 和 sitemap 使用同一套 locale 配置。
常见坑
- 页面 metadata 有 hreflang,但 sitemap 没有。
- sitemap 有所有 locale,但页面 robots 对其中一些 locale 是
noindex。 zh-CN/en-US大小写不规范。- 默认语言同时存在
/page和/en-US/page两个可索引版本。
hreflang helper 示例
const LOCALES = ['en-US', 'zh-CN', 'ja-JP'];
const DEFAULT_LOCALE = 'en-US';
const SITE_URL = 'https://example.com';
function toHreflang(locale: string) {
const [language, region] = locale.split('-');
return region ? `${language.toLowerCase()}-${region.toUpperCase()}` : language;
}
export function generateLanguageAlternates(path: string) {
const languages: Record<string, string> = {};
for (const locale of LOCALES) {
const localizedPath = locale === DEFAULT_LOCALE ? path : `/${locale}${path}`;
languages[toHreflang(locale)] = new URL(localizedPath, SITE_URL).href;
}
languages['x-default'] = new URL(path, SITE_URL).href;
return languages;
}5. Metadata:让搜索结果和分享卡片可控
要解决的问题
metadata 决定页面在搜索结果、社交分享、浏览器和爬虫语义里的第一印象。工程上要保证每类页面都有稳定模板,同时允许 CMS 或业务内容覆盖。
实践清单
- 每个页面至少有 title、description、canonical。
- 可分享页面补 Open Graph 和 Twitter Card。
- 详情页优先使用内容自己的图片,不要全站共用一张兜底图。
- CMS 内容页优先使用 CMS
meta.title/meta.description。 - CMS 不可用时有 i18n fallback,不能让页面渲染失败。
- Metadata 文案走 i18n,不写 JSX 裸字符串。
Next.js 写法
export async function generateMetadata({ params }): Promise<Metadata> {
const { locale } = await params;
const path = '/your-page-path';
return {
title: metaTitle,
description: metaDescription,
alternates: {
canonical: generateCanonical(locale, path),
languages: generateLanguageAlternates(path),
},
openGraph: {
title: metaTitle,
description: metaDescription,
images: [{ url: socialImage, width: 1200, height: 630 }],
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: metaTitle,
description: metaDescription,
images: [socialImage],
},
};
}6. Structured Data:把页面升级成实体关系
要解决的问题
搜索引擎可以阅读 HTML,但 JSON-LD 能更直接地说明页面是什么、属于谁、和哪些实体有关。结构化数据尤其适合内容页、产品页、FAQ、视频、面包屑。
实践清单
- 全站 layout 放 Organization / WebSite。
- 文章详情页放 Article。
- 内容详情页放 BreadcrumbList。
- FAQ 内容真实存在时放 FAQPage。
- 视频内容放 VideoObject,并提供 thumbnail / embed / content URL。
- JSON-LD 中的 URL 使用 canonical。
- 注入 JSON-LD 时转义
<,避免脚本注入风险。
JSON-LD helper 示例
export function articleJsonLd(article: {
title: string;
description: string;
url: string;
image?: string;
publishedAt?: string;
updatedAt?: string;
}) {
return {
'@context': 'https://schema.org',
'@type': 'Article',
headline: article.title,
description: article.description,
url: article.url,
image: article.image,
datePublished: article.publishedAt,
dateModified: article.updatedAt,
};
}安全注入写法
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replace(/</g, '\\u003c'),
}}
/>7. Rendering 与性能:让爬虫和用户都拿到稳定页面
要解决的问题
搜索引擎可以执行 JavaScript,但工程上仍然应该让关键内容尽早、稳定地出现在服务端返回的 HTML 中。SEO 页面越依赖客户端二次请求,越容易出现抓取不完整、性能差、内容闪烁和缓存不可控。
实践清单
- 纯入口页优先静态化。
- 内容详情页可用 SSG / ISR。
- 搜索、排序、筛选页如果必须动态,明确
noindex或 canonical 策略。 - CMS fetch 使用缓存和 tags,避免每次请求都打源站。
- 关键内容不要只在客户端 useEffect 后出现。
- 图片走受控 loader / CDN / remotePatterns。
- 页面大集合不要全量
generateStaticParams,避免构建膨胀。
典型取舍
内容集合可以把入口页和探索页拆成两个路由:
- 入口页:静态、可索引,承载 SEO 权重。
- 探索页:动态、支持搜索排序分页,
noindex, follow,canonical 指回入口页。
这是一个很实用的模式:把“搜索引擎入口”和“用户探索体验”分开设计。
页面渲染策略示例
静态入口页:
export const dynamic = 'force-static';
export const revalidate = 60 * 60 * 12;动态探索页:
export const dynamic = 'force-dynamic';// 动态探索页的 metadata:不让参数页抢主页面权重
export async function generateMetadata() {
return {
robots: { index: false, follow: true },
alternates: {
canonical: 'https://example.com/topic',
},
};
}8. Data Loop:上线后验证 SEO 是否生效
要解决的问题
SEO 工程不是合并代码就结束。上线后要确认页面真的被发现、被抓取、被索引,并带来查询曝光和点击。
实践清单
- 在 Search Console 里提交 sitemap。
- 检查 sitemap 读取状态和 URL 数量。
- 用 URL Inspection 看页面是否可抓取、是否被索引、Google 选择的 canonical 是谁。
- 观察 Coverage / Pages 报告中的
noindex、duplicate、discovered currently not indexed。 - 按 query / page / country / device 分析表现。
- 定期抽查 robots、canonical、hreflang、JSON-LD 是否和预期一致。
- 对内容库建立索引率指标:提交 URL 数、已索引 URL 数、带来点击 URL 数。
新页面 SEO 实现模板
新增一个公开页面时,可以按这个顺序做:
- 判断页面类型:入口页、内容详情、列表、动态探索页、个人结果页。
- 决定索引策略:
index还是noindex,是否follow。 - 决定 canonical:当前 URL 还是指向主入口页 / 无参数 URL。
- 补 metadata:title、description、OG、Twitter。
- 补 hreflang:使用统一 helper。
- 判断是否加入 sitemap。
- 判断是否需要 JSON-LD。
- 判断渲染策略:static、ISR、dynamic。
- 验证非生产环境不会被索引。
- 上线后用 Search Console 验证抓取和索引。
页面类型决策表
| 页面类型 | 推荐索引 | canonical | sitemap | 结构化数据 |
|---|---|---|---|---|
| 静态入口页 | index, follow | 自己 | 是 | WebPage / FAQPage |
| 文章 / 案例详情 | index, follow | 自己 | 是 | Article / BreadcrumbList |
| 内容详情 | 按质量门槛 | 自己 | 仅放可索引集合 | CreativeWork / VideoObject |
| 探索页 / 搜索结果页 | noindex, follow | 主入口页或无参数 URL | 通常否 | 视情况 |
| 个人结果页 | noindex, nofollow | 自己或无 | 否 | 否 |
| 登录后页面 | robots 屏蔽或 noindex | 无 | 否 | 否 |
| API / 内部路由 | robots 屏蔽 | 无 | 否 | 否 |
Code Review 检查清单
- 页面是否有
generateMetadata? - title / description 是否来自 i18n 或 CMS?
- canonical 是否用统一 helper?
- hreflang 是否覆盖所有支持 locale?
- robots 策略是否符合页面类型?
- sitemap 是否和 robots 策略一致?
- 动态参数页是否会制造重复索引?
- OG / Twitter 图片是否是内容相关图片?
- JSON-LD 是否引用 canonical URL?
- JSON-LD 是否做了
<转义? - 关键内容是否服务端可见?
- CMS 请求是否有缓存和失败兜底?
- 非生产环境是否被 robots 阻止?
阅读代码时的三个问题
读实现时不要只看“它写了什么标签”,而要问三个问题:
- 这个页面为什么应该或不应该被索引?
- 这个 URL 的权威版本是谁?
- sitemap、metadata、robots、页面内容是否互相一致?
参考资料
入门概念
- Ahrefs:SEO 基础知识 - 适合先建立 SEO 的整体概念,包括 SEO 是什么、为什么重要、基础设置、收录和效果衡量。
搜索引擎官方文档
- Google Search Central: SEO Starter Guide
- Google Search Central: Control crawling and indexing
- Google Search Central: Consolidate duplicate URLs
- Google Search Central: Localized versions with hreflang
- Google Search Central: Structured data
框架实现文档
- Next.js Docs: Metadata API
- Next.js Docs:
generateMetadata - Next.js Docs: sitemap and robots metadata files