Next.js 内容站 SEO:先排查这几个坑

Next.js 本身不破坏 SEO,但 Next.js 16 有几个真坑:异步 params、流式 metadata、动态路由。上线前用这套 metadata、sitemap.ts、view-source 清单过一遍。

大多数”Next.js SEO 不行”的帖子,真实原因往往是”我忘了 export metadata""该静态的 route 被我留成动态了”,或者(2025 年底之后越来越常见)“我一直没迁移到异步 params API”。框架本身没问题,是默认值会咬人。本文就是上线一个 Next.js 16 内容站、让 Google 索引之前,要逐项检查的清单,每一步都配了 App Router 代码。

一句话结论

  • next build。每个公开文章 route 都要打印成 (静态)或 (SSG),不能是 ƒ(动态)。内容页是动态,通常就是漏了 generateStaticParams
  • Next.js 16 里 paramssearchParams 都是 Promise。必须在 generateMetadata、页面组件、generateStaticParams 回调里 await params,否则构建直接报错。
  • 每个动态页都 export generateMetadata(canonical + alternates.languages + Open Graph),加 app/sitemap.tsapp/robots.ts,并在服务端发 Article JSON-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 devnext build 的默认打包器。Pages Router 还能用,但已进入维护状态,新内容站应该用 App Router。
  • 搞清楚部署目标(Vercel / Cloudflare Workers·Pages / Netlify),从而知道你交付的是静态导出还是带服务端运行时。
  • 选一个测试用的样本文章 slug。

异步 params 这个坑(Next.js 15 → 16)

升级后的内容站,最常见的崩点就是这个。Next.js 15 里同步访问 params 被标了弃用警告;到了 Next.js 16 直接移除paramssearchParamscookies()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')里 export metadatagenerateMetadata。会被静默忽略。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.tsrobots.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 #SEO #Technical SEO #Canonical #Core Web Vitals