Next.js 内容站 SEO 注意什么

Next.js 本身不破坏 SEO 但有几个坑。上线前用这套 metadata API、sitemap.ts、view-source 检查清单过一遍。

大多数”Next.js SEO 不行”的帖子其实是”我忘了 export metadata”或”应该静态的 route 让它变动态了”。框架没问题,是默认值会咬人。本文是告诉 Google 索引你站之前要检查的清单,每一步都有 App Router 的具体代码。

问题背景

2026 年搜索引擎能渲染 JS 但不喜欢渲染。初始响应里就带 title、description、canonical、结构化数据和正文的静态 HTML,渲染、排序、缓存都比依赖 hydration 的 payload 快。Next.js 能产出这种,前提是你配对了。

判断标准

  • GSC 提示一片”已索引但被 robots.txt 屏蔽”或”已发现 – 尚未编入索引”。
  • 查看源代码发现正文不在,或者包在 <div id="__next"> 壳里。
  • Lighthouse SEO 分数不到 100,提示”页面没有 meta description”或”链接无法被爬取”。
  • 页面有重复 <title> 或没 canonical 标签。
  • next build 输出里文章 route 是 Dynamic(λ)而不是 Static()。

快速结论

部署后的文章 view-source: 里能看到完整正文和 metadata,就完成 90%。再加 sitemap、robots、JSON-LD 就齐了。

开始前准备

  • 新站优先 App Router(不是 Pages Router)——下面示例都基于 App Router。
  • 部署目标已知(Vercel / Cloudflare Pages / Netlify)。
  • 测试用的样本文章 slug 选好。

实操步骤

  1. 确认 route 是静态。next build 看输出:
Route (app)                              Size  First Load JS
┌ ○ /                                    140 B       91.2 kB
├ ○ /articles/[slug]                    1.34 kB     94.6 kB   ← ○ 静态
├ λ /api/foo                             0 B            0 B   ← λ 动态(API 没问题)
└ ƒ /articles/dynamic-route             3.21 kB     97.1 kB   ← ƒ 动态(内容页是红灯)

公开文章页是 ƒ Dynamic 就是红灯——通常是漏了 generateStaticParams

  1. 每个页面都 export metadatagenerateMetadata app/articles/[slug]/page.tsx
import type { Metadata } from 'next';
import { getArticle, getAllSlugs } from '@/lib/content';

export async function generateStaticParams() {
  return getAllSlugs().map((slug) => ({ slug }));
}

export async function generateMetadata(
  { params }: { params: { slug: string } }
): Promise<Metadata> {
  const article = await getArticle(params.slug);
  return {
    title: article.title,
    description: article.description,
    alternates: {
      canonical: `https://yourdomain.com/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/articles/${article.urlSlug}/`,
      type: 'article',
      publishedTime: article.publishedAt.toISOString(),
    },
    twitter: { card: 'summary_large_image', title: article.title },
  };
}
  1. app/sitemap.ts 列出所有已发布文章:
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.flatMap((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}/`,
          },
        },
      },
    ]),
  ];
}
  1. app/robots.ts 引用 sitemap:
import { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [{ userAgent: '*', allow: '/' }],
    sitemap: 'https://yourdomain.com/sitemap.xml',
    host: 'https://yourdomain.com',
  };
}
  1. 服务端发 JSON-LD 结构化数据:
export default async function ArticlePage({ params }) {
  const a = await getArticle(params.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/articles/${a.urlSlug}/`,
  };
  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(ld) }}
      />
      <article>{/* … */}</article>
    </>
  );
}
  1. next.config.js 一次定下 trailing slash:
/** @type {import('next').NextConfig} */
module.exports = {
  trailingSlash: true,        // 选一个,永远别改
  poweredByHeader: false,
  async redirects() {
    return [{ source: '/blog/:slug', destination: '/articles/:slug/', permanent: true }];
  },
};
  1. 部署后看源代码。 这些要全部在原始 HTML 里,不能由客户端 JS 后加:
curl -sL https://yourdomain.com/articles/test-slug/ | grep -E '<title>|description|canonical|hreflang|application/ld\+json' | head

如果正文文本也能看到(curl -sL ... | grep "正文里某句话" 能命中),就是爬虫友好的 HTML。

  1. Search Console 提交 sitemap,并把 Lighthouse SEO 调到 100。

执行检查清单

  • next build 输出文章 route 是 ○ Static
  • generateMetadata 每个动态页都 export canonical、openGraph、alternates。
  • app/sitemap.tsapp/robots.ts 都有,生产 URL 能访问。
  • JSON-LD 服务端发,不靠客户端 JS 后加。
  • 部署后文章 view-source 包含 title、description、canonical、JSON-LD、正文。

上线后验证

  • 至少 3 篇样本文章 Lighthouse SEO = 100。
  • Search Console URL Inspection 抽查样本 1-2 周内显示 “URL is on Google”。
  • curl -sL 验证初始 HTML 含 hreflang 对。

容易踩的坑

  • 在 client component 里设 metadata——不会生效。metadata 必须从 server component(page 或 layout)export。
  • 在 layout 里调 redirect()notFound(),结果回了 200 但内容空。
  • [slug] route 忘了 generateStaticParams——动态段不会预渲染。
  • next/image 没给 LCP 图加 priority,LCP 跌到掉排名。
  • sitemap、canonical、实际 URL 的结尾斜杠不一致——Google 把 /foo/foo/ 当不同 URL。
  • robots.txt 屏蔽了 _next/static——CSS 和 JS 也被屏,渲染和排名都跌。
  • 服务端渲染内容包在没有 fallback HTML 的 Suspense 里——Google 看到的只是 loading。

FAQ

  • Google 真的会渲染 JS 吗?: 会但有延迟、有选择。静态 HTML 在 crawl budget、索引速度、排名上仍然占优,新站尤其明显。
  • hreflang 要单独配吗?: 不用——metadata 里 alternates.languages 自动生成对应 <link rel="alternate" hreflang>
  • ISR 还是纯静态?: 内容真不变用纯静态。想不重新部署也能推改动用 ISR。SEO 上等价。
  • next/image 对 SEO 友好吗?: 配对了就友好——自动响应式 srcset、懒加载、正确尺寸,LCP 和 CLS 都受益。
  • tag 页和分页要 noindex 吗?: 标签页内容不实之前一般要;分页可以 index 只要每页列表不同。

相关阅读

标签: #独立开发 #Next.js #SEO #Technical SEO #Canonical #Core Web Vitals