大多数”Next.js SEO 不行”的帖子,真实原因往往是”我忘了 export metadata""该静态的 route 被我留成动态了”,或者(2025 年底之后越来越常见)“我一直没迁移到异步 params API”。框架本身没问题,是默认值会咬人。本文就是上线一个 Next.js 16 内容站、让 Google 索引之前,要逐项检查的清单,每一步都配了 App Router 代码。
一句话结论
- 跑
next build。每个公开文章 route 都要打印成○(静态)或●(SSG),不能是ƒ(动态)。内容页是动态,通常就是漏了generateStaticParams。 - Next.js 16 里
params和searchParams都是 Promise。必须在generateMetadata、页面组件、generateStaticParams回调里await params,否则构建直接报错。 - 每个动态页都 export
generateMetadata(canonical +alternates.languages+ Open Graph),加app/sitemap.ts和app/robots.ts,并在服务端发ArticleJSON-LD。 - 用
curl -sL抓一篇线上文章:title、description、canonical、hreflang、JSON-LD 以及正文文本,都要在原始 HTML 里,不能靠客户端 JS 后补。
为什么 2026 年静态 HTML 仍然占优
Googlebot 会渲染 JavaScript,但渲染是第二趟、按它自己的排期跑、还要烧 crawl budget。初始响应里就带着 title、description、canonical、结构化数据和完整正文的页面,被发现、索引、缓存都比依赖 hydration 或客户端请求的页面快。对一个还在抢 crawl budget 的新站来说,这个差距就是”几天内被索引”和”已发现、尚未编入索引拖好几周”的区别。Next.js 16 默认就能产出纯静态 HTML,下面这些坑,正是悄悄把它打回客户端渲染外壳的东西。
怎么判断你有问题
- Google Search Console 一片”已发现 – 尚未编入索引”或”已抓取 – 尚未编入索引”。
view-source:看页面,正文不在,或者包在空的 app 外壳里,内容要等 JS 跑完才出现。- Lighthouse SEO 不到 100,提示”页面没有 meta description”或”链接无法被爬取”。
- 页面有重复
<title>,或者没有 canonical 标签。 next build把文章 route 标成ƒ(动态)而不是○(静态)。
快速判定
部署后的文章 view-source: 里能看到完整正文加上那几个 metadata 标签,就已经完成 90%。再加 sitemap、robots、JSON-LD 就齐了。
开始前准备
- 本文假设用 App Router,运行在 Next.js 16(截至 2026 年 6 月为 16.2.x),Turbopack 已是
next dev和next build的默认打包器。Pages Router 还能用,但已进入维护状态,新内容站应该用 App Router。 - 搞清楚部署目标(Vercel / Cloudflare Workers·Pages / Netlify),从而知道你交付的是静态导出还是带服务端运行时。
- 选一个测试用的样本文章 slug。
异步 params 这个坑(Next.js 15 → 16)
升级后的内容站,最常见的崩点就是这个。Next.js 15 里同步访问 params 被标了弃用警告;到了 Next.js 16 直接移除:params、searchParams、cookies()、headers() 全部只能异步访问。如果你的 generateMetadata 或页面还在同步解构 params,要么构建报错,要么 route 悄悄回退成动态渲染。把 params 类型写成 Promise,到处都 await。
// ❌ Next.js 14 写法——在 16 上会崩
export async function generateMetadata({ params }: { params: { slug: string } }) {
const article = await getArticle(params.slug); // params 现在是 Promise
}
// ✅ Next.js 16 写法
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props) {
const { slug } = await params;
const article = await getArticle(slug);
}
Next.js 自带一个 codemod(npx @next/codemod@latest next-async-request-api .),能自动改掉大部分调用点。跑完之后,再 grep 一遍有没有漏改、还在没 await 就解构 params. 的地方。
一步步来
1. 确认 route 是静态
跑 next build,看路由表。开头那个符号表示渲染策略:
| 符号 | 含义 | 内容页适用吗 |
|---|---|---|
○ | 静态(构建时预渲染成 HTML) | 适用 |
● | 带 generateStaticParams 的 SSG(按 slug 静态) | 适用 |
ƒ | 动态(每次请求在服务端渲染) | 公开文章不适用 |
Route (app) Size First Load JS
┌ ○ / 140 B 91.2 kB
├ ● /[lang]/articles/[slug] 1.34 kB 94.6 kB ← ● 按 slug 静态(好)
├ ƒ /api/search 0 B 0 B ← ƒ 动态 API(没问题)
└ ƒ /articles/preview 3.21 kB 97.1 kB ← ƒ 动态内容(红灯)
公开文章是 ƒ,基本就是漏了 generateStaticParams,或者页面读了 cookies()/searchParams/headers() 把自己逼成了动态。
2. 每个页面都 export generateMetadata
app/[lang]/articles/[slug]/page.tsx——注意 Promise 类型,以及在根 layout 里设一次 metadataBase 让相对 URL 能解析:
import type { Metadata } from 'next';
import { getArticle, getAllSlugs } from '@/lib/content';
type Props = { params: Promise<{ slug: string; lang: string }> };
export async function generateStaticParams() {
return getAllSlugs().map((slug) => ({ slug }));
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const article = await getArticle(slug);
return {
title: article.title,
description: article.description,
alternates: {
canonical: `https://yourdomain.com/en/articles/${article.urlSlug}/`,
languages: {
'en-US': `https://yourdomain.com/en/articles/${article.urlSlug}/`,
'zh-CN': `https://yourdomain.com/zh/articles/${article.urlSlug}/`,
'x-default': `https://yourdomain.com/en/articles/${article.urlSlug}/`,
},
},
openGraph: {
title: article.title,
description: article.description,
url: `https://yourdomain.com/en/articles/${article.urlSlug}/`,
type: 'article',
publishedTime: article.publishedAt.toISOString(),
},
twitter: { card: 'summary_large_image', title: article.title },
};
}
在 app/layout.tsx 里设一次 metadataBase: new URL('https://yourdomain.com')。不设的话,Open Graph 图用相对 URL 会在构建时报错,canonical 也容易混进绝对/相对路径的 bug。
3. 加 app/sitemap.ts
把每篇已发布文章连同语言 alternates 一起列出来:
import { MetadataRoute } from 'next';
import { getAllArticles } from '@/lib/content';
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const articles = await getAllArticles();
return [
{ url: 'https://yourdomain.com/', changeFrequency: 'weekly', priority: 1 },
...articles.map((a) => ({
url: `https://yourdomain.com/en/articles/${a.urlSlug}/`,
lastModified: a.updatedAt ?? a.publishedAt,
changeFrequency: 'monthly' as const,
priority: 0.6,
alternates: {
languages: {
'en-US': `https://yourdomain.com/en/articles/${a.urlSlug}/`,
'zh-CN': `https://yourdomain.com/zh/articles/${a.urlSlug}/`,
},
},
})),
];
}
单个 sitemap.xml 要控制在 50,000 条 URL、50 MB 以内。超了就用 generateSitemaps 返回多个 sitemap,再用一个 sitemap index 引用它们。
4. 加 app/robots.ts
import { MetadataRoute } from 'next';
export default function robots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: '*', allow: '/' }],
sitemap: 'https://yourdomain.com/sitemap.xml',
host: 'https://yourdomain.com',
};
}
5. 服务端发 JSON-LD
让 <script> 从服务端组件渲染,这样它会落进初始 HTML。记得 await params:
export default async function ArticlePage({ params }: Props) {
const { slug } = await params;
const a = await getArticle(slug);
const ld = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: a.title,
description: a.description,
datePublished: a.publishedAt,
dateModified: a.updatedAt ?? a.publishedAt,
author: { '@type': 'Organization', name: a.author },
mainEntityOfPage: `https://yourdomain.com/en/articles/${a.urlSlug}/`,
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }}
/>
<article>{/* 文章正文 */}</article>
</>
);
}
上线前先用 Google 的 Rich Results Test 验证输出,别盲信。
6. 在 next.config 里锁死结尾斜杠
选一个,永远别改。canonical、sitemap 里的 URL、线上 URL 必须一致,因为 Google 把 /foo 和 /foo/ 当成两个 URL:
/** @type {import('next').NextConfig} */
module.exports = {
trailingSlash: true, // 选一个,永远保持
poweredByHeader: false,
async redirects() {
return [{ source: '/blog/:slug', destination: '/articles/:slug/', permanent: true }];
},
};
7. 看部署后页面的源代码
确认 metadata 和正文都在原始 HTML 里,不是客户端 JS 后加的:
curl -sL https://yourdomain.com/en/articles/test-slug/ \
| grep -E '<title>|description|canonical|hreflang|application/ld\+json'
再确认正文也是服务端渲染的:
curl -sL https://yourdomain.com/en/articles/test-slug/ | grep "正文里某句话"
那句话能命中一行,就是爬虫友好的 HTML。
8. 提交 sitemap 并验证
在 Search Console 提交 sitemap.xml,然后对三篇样本文章跑 Lighthouse,确认 SEO = 100。
流式 metadata 这个坑
从 Next.js 15.2 起,当一个 route 没有被完全预渲染(它读了运行时数据),generateMetadata 可能在初始 UI 发出之后才解析,于是 metadata 标签被追加到响应靠后的位置,而不是待在 <head> 里。Googlebot 会执行 JS、读完整 DOM,所以它看得到。但 不跑 JS 的 HTML-limited 爬虫——facebookexternalhit、一些链接预览爬虫——只读 <head>,就可能漏掉你的 Open Graph 标签,把分享卡渲染成一片空白。
修法在源头:把文章 route 保持完全静态(第 1、2 步),让 metadata 留在 <head>。如果你确实需要运行时数据,可以在 next.config 里强制对所有爬虫都阻塞式生成 metadata:
// next.config.ts —— 让 metadata 对每个 user agent 都阻塞
import type { NextConfig } from 'next';
const config: NextConfig = { htmlLimitedBots: /.*/ };
export default config;
非必要别用。它会让你失去流式 metadata 带来的 TTFB 优势。
容易踩的坑
- Next.js 16 上同步读
params/searchParams/cookies()。现在都是 Promise,要await,否则构建报错。 - 在 client component(
'use client')里 exportmetadata或generateMetadata。会被静默忽略。metadata 必须来自 server component。 [slug]route 忘了generateStaticParams,于是动态渲染,构建里显示成ƒ。- 漏了
metadataBase,Open Graph 图用相对 URL 会抛错,生成的绝对 URL 也会出错。 next/image没给 LCP 图加priority,LCP 拖过 2.5 秒的”良好”阈值,拉低 Core Web Vitals。- sitemap、canonical、线上 URL 的结尾斜杠不一致。
- robots 里 disallow 了
/_next/static/,把 Google 挡在 CSS 和 JS 外面,渲染直接坏掉。 - 服务端渲染内容包在没有静态 fallback 的
<Suspense>里,不跑 JS 的爬虫只看到一个 loading。
FAQ
- Google 真的会渲染 JS 吗?: 会,但是在延迟的第二趟里跑,还要消耗 crawl budget。静态 HTML 在索引速度和抓取效率上仍然占优,对新站影响最大。
- hreflang 要单独配吗?: 不用。metadata 里的
alternates.languages会生成<link rel="alternate" hreflang>标签,app/sitemap.ts也能同步镜像一份。 - 新内容站用 Pages Router 还是 App Router?: App Router。metadata、
sitemap.ts、robots.ts这套文件约定只在 App Router 里有,而且截至 Next.js 16,Pages Router 已进入维护状态。 - ISR 还是纯静态?: 内容很少变就纯静态;想不整体重新部署也能推改动,就用 ISR(
revalidate)。两者 SEO 上等价,因为都给爬虫返回缓存好的 HTML。 next/image对 SEO 友好吗?: 配对了就友好——自动响应式srcset、懒加载、明确尺寸,对 LCP 和 CLS 都有帮助。记得给 LCP 图加priority。- tag 页和分页要 noindex 吗?: 标签页内容单薄时先 noindex,等有了实质列表再放开;分页只要每页列表不同,可以保持 index。
相关阅读
- Next.js sitemap / robots 基础
- Next.js 图片优化基础
- 2026 年新站如何提交给 Google
- Next.js 不适合做内容站的情况
- Next.js App Router 概念
标签: #独立开发 #Next.js #SEO #Technical SEO #Canonical #Core Web Vitals