第 18 章:SEO 深度优化(Metadata API 与结构化数据)

在 Next.js App Router 中构建完整的 SEO 体系——Metadata API 深度使用、@vercel/og 动态封面图、Sitemap 自动生成、结构化数据、国际化 hreflang 与搜索引擎监控。

本章目标:构建一套完整的 Next.js SEO 工程体系——从 Metadata API 的三种使用模式、动态 OG 图片生成、Sitemap / robots.txt 配置,到结构化数据、国际化 SEO 与搜索引擎监控,让你的站点在搜索引擎中获得最佳排名。


18.1 SEO 基础概念

搜索引擎工作流程

搜索引擎工作三步曲:

1. 爬取(Crawling)
   Googlebot 访问你的网站,发现页面和链接

2. 索引(Indexing)
   分析页面内容、结构、元数据,存储到索引库

3. 排名(Ranking)
   根据 200+ 信号(内容质量、反向链接、Core Web Vitals 等)
   决定在搜索结果中的位置

Next.js 的 SEO 优势

特性SPA (React/Vue)Next.js
HTML 可爬取❌(需要 JS 渲染)✅(SSR / SSG 直出 HTML)
<head> 标签手动管理✅ Metadata API
每个页面独立 URL⚠️(需 hash / history)✅ 文件系统路由
Canonical URL手动✅ 自动生成
Sitemap需第三方工具✅ 内置 sitemap.ts
robots.txt手动✅ 内置 robots.ts
OG 图片需外部服务@vercel/og

18.2 Metadata API 三种模式

模式一:静态 Metadata

适用于固定内容的页面(About、Contact 等):

// app/about/page.tsx

import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: '关于我们',
  description: '了解 MyBlog 团队的故事、使命与愿景。',
};

export default function AboutPage() {
  return <div>{/* ... */}</div>;
}

模式二:动态 Metadata

适用于数据驱动的页面(文章、产品、用户资料等):

// app/articles/[slug]/page.tsx

import type { Metadata } from 'next';
import { getArticle } from '@/lib/services/article';
import { notFound } from 'next/navigation';

type Props = {
  params: Promise<{ slug: string }>;
};

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const article = await getArticle(slug);

  if (!article) {
    return {
      title: '文章未找到',
    };
  }

  return {
    title: article.title,
    description: article.excerpt || `${article.title} - 详细内容`,
    authors: article.author?.name ? [{ name: article.author.name }] : [],
    publishedTime: article.publishedAt?.toISOString(),
    modifiedTime: article.updatedAt?.toISOString(),

    openGraph: {
      title: article.title,
      description: article.excerpt || '',
      type: 'article',
      publishedTime: article.publishedAt?.toISOString(),
      modifiedTime: article.updatedAt?.toISOString(),
      authors: article.author?.name ? [article.author.name] : [],
      tags: article.tags,
      images: [
        {
          url: article.coverImage || `/api/og?title=${encodeURIComponent(article.title)}`,
          width: 1200,
          height: 630,
          alt: article.title,
        },
      ],
    },

    twitter: {
      card: 'summary_large_image',
      title: article.title,
      description: article.excerpt || '',
      images: [article.coverImage || `/api/og?title=${encodeURIComponent(article.title)}`],
    },

    alternates: {
      canonical: `https://example.com/articles/${article.slug}`,
    },
  };
}

模式三:模板化 Metadata

适用于需要统一后缀 / 前缀的站点:

// app/layout.tsx

import type { Metadata } from 'next';

export const metadata: Metadata = {
  // 模板:子页面的 title 会替换 %s
  title: {
    default: 'MyBlog - Next.js 技术博客',
    template: '%s | MyBlog',
  },
  description: '系统学习 Next.js App Router,从入门到生产实战。',
  metadataBase: new URL('https://example.com'),

  // OpenGraph 默认值(子页面可覆盖)
  openGraph: {
    type: 'website',
    locale: 'zh_CN',
    siteName: 'MyBlog',
    images: [
      {
        url: '/og-default.png',
        width: 1200,
        height: 630,
        alt: 'MyBlog',
      },
    ],
  },

  twitter: {
    card: 'summary_large_image',
    creator: '@bingrong',
  },

  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      'max-video-preview': -1,
      'max-image-preview': 'large',
      'max-snippet': -1,
    },
  },

  // 验证标签
  verification: {
    google: 'google-site-verification-code',
    yandex: 'yandex-verification-code',
  },

  // 图标
  icons: {
    icon: [
      { url: '/favicon.ico' },
      { url: '/icon.png', type: 'image/png', sizes: '32x32' },
    ],
    apple: [
      { url: '/apple-icon.png', sizes: '180x180', type: 'image/png' },
    ],
  },

  // Manifest
  manifest: '/manifest.json',
};

子页面只需指定 title,其余继承默认值:

// app/articles/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const article = await getArticle(params.slug);
  return {
    title: article.title,        // 自动变为 "文章标题 | MyBlog"
    description: article.excerpt, // 覆盖父级
    // openGraph 从父级继承,但 images 需要覆盖
  };
}

Metadata 字段速查表

const metadata: Metadata = {
  // 基础
  title: '页面标题',
  description: '页面描述',
  keywords: ['nextjs', 'react'],
  authors: [{ name: 'bingrong', url: 'https://example.com' }],
  creator: 'bingrong',
  publisher: 'MyBlog',

  // 高级
  metadataBase: new URL('https://example.com'),
  alternates: {
    canonical: 'https://example.com/articles/hello',
    languages: {
      'zh-CN': 'https://example.com/zh/articles/hello',
      'en-US': 'https://example.com/en/articles/hello',
    },
    types: {
      'application/rss+xml': '/rss.xml',
    },
  },

  // OpenGraph
  openGraph: {
    type: 'website', // website | article | profile | ...
    url: 'https://example.com',
    title: '标题',
    description: '描述',
    siteName: '站点名',
    images: [{ url: '/og.png', width: 1200, height: 630, alt: '描述' }],
    locale: 'zh_CN',
    alternateLocale: 'en_US',
  },

  // Twitter
  twitter: {
    card: 'summary_large_image', // summary | summary_large_image | app | player
    site: '@site',
    creator: '@creator',
    title: '标题',
    description: '描述',
    images: ['/twitter-image.png'],
  },

  // Robots
  robots: {
    index: true,
    follow: true,
    nocache: false,
    googleBot: { index: true, follow: true },
  },

  // 其他
  viewport: { width: 'device-width', initialScale: 1, maximumScale: 1 },
  themeColor: [
    { media: '(prefers-color-scheme: light)', color: '#ffffff' },
    { media: '(prefers-color-scheme: dark)', color: '#0f172a' },
  ],
  category: 'technology',
};

18.3 动态 OpenGraph 图片生成

安装 @vercel/og

npm install @vercel/og

注意@vercel/og 依赖 Edge Runtime,需要在 Route Handler 中使用。

基础 OG 图片

// app/api/og/route.tsx

import { ImageResponse } from 'next/og';
import type { NextRequest } from 'next/server';

export const runtime = 'edge';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get('title') || '默认标题';
  const category = searchParams.get('category') || 'Next.js';
  const author = searchParams.get('author') || 'bingrong';

  return new ImageResponse(
    (
      <div
        style={{
          height: '100%',
          width: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'space-between',
          backgroundColor: '#0f172a',
          backgroundImage:
            'radial-gradient(circle at 25px 25px, #1e293b 2%, transparent 0%), radial-gradient(circle at 75px 75px, #1e293b 2%, transparent 0%)',
          backgroundSize: '100px 100px',
          padding: '60px 80px',
          color: 'white',
        }}
      >
        {/* 顶部品牌 */}
        <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
          <div
            style={{
              width: '48px',
              height: '48px',
              borderRadius: '12px',
              background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
              display: 'flex',
              alignItems: 'center',
              justifyContent: 'center',
              fontSize: '24px',
              fontWeight: 'bold',
            }}
          >
            M
          </div>
          <span style={{ fontSize: '28px', fontWeight: '600' }}>MyBlog</span>
        </div>

        {/* 标题 */}
        <div>
          <div
            style={{
              fontSize: '20px',
              fontWeight: '500',
              color: '#60a5fa',
              marginBottom: '16px',
            }}
          >
            {category}
          </div>
          <h1
            style={{
              fontSize: '64px',
              fontWeight: 'bold',
              lineHeight: 1.15,
              margin: 0,
              maxWidth: '900px',
            }}
          >
            {title}
          </h1>
        </div>

        {/* 底部作者 */}
        <div
          style={{
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'space-between',
          }}
        >
          <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
            <div
              style={{
                width: '40px',
                height: '40px',
                borderRadius: '50%',
                background: 'linear-gradient(135deg, #f59e0b, #ef4444)',
              }}
            />
            <span style={{ fontSize: '22px' }}>{author}</span>
          </div>
          <span style={{ fontSize: '18px', color: '#94a3b8' }}>
            example.com
          </span>
        </div>
      </div>
    ),
    {
      width: 1200,
      height: 630,
    }
  );
}

使用自定义字体

// app/api/og/route.tsx

import { ImageResponse } from 'next/og';

export const runtime = 'edge';

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url);
  const title = searchParams.get('title') || '默认标题';

  // 加载字体
  const [interBold, interMedium] = await Promise.all([
    fetch(new URL('@/public/fonts/Inter-Bold.ttf', import.meta.url)).then(
      (res) => res.arrayBuffer()
    ),
    fetch(new URL('@/public/fonts/Inter-Medium.ttf', import.meta.url)).then(
      (res) => res.arrayBuffer()
    ),
  ]);

  return new ImageResponse(
    (
      <div
        style={{
          // ...样式
          fontFamily: 'Inter',
        }}
      >
        <h1 style={{ fontWeight: 700 }}>{title}</h1>
        <p style={{ fontWeight: 500 }}>副标题</p>
      </div>
    ),
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: 'Inter',
          data: interBold,
          style: 'normal',
          weight: 700,
        },
        {
          name: 'Inter',
          data: interMedium,
          style: 'normal',
          weight: 500,
        },
      ],
    }
  );
}

在 Metadata 中使用 OG 图片

// app/articles/[slug]/page.tsx

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const article = await getArticle(params.slug);
  if (!article) return { title: '文章未找到' };

  // 使用动态 OG 图片
  const ogImageUrl = new URL('/api/og', process.env.NEXT_PUBLIC_APP_URL);
  ogImageUrl.searchParams.set('title', article.title);
  ogImageUrl.searchParams.set('category', article.category || 'Next.js');
  ogImageUrl.searchParams.set('author', article.author?.name || 'bingrong');

  return {
    title: article.title,
    description: article.excerpt,
    openGraph: {
      title: article.title,
      description: article.excerpt || '',
      images: [
        {
          url: ogImageUrl.toString(),
          width: 1200,
          height: 630,
          alt: article.title,
        },
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: article.title,
      images: [ogImageUrl.toString()],
    },
  };
}

缓存 OG 图片

// app/api/og/route.tsx

export async function GET(request: NextRequest) {
  // ... 生成图片

  return new ImageResponse(
    (/* JSX */),
    {
      width: 1200,
      height: 630,
      headers: {
        // 缓存 30 天
        'Cache-Control': 'public, max-age=2592000, s-maxage=2592000, immutable',
      },
    }
  );
}

18.4 Sitemap 自动生成

静态 Sitemap

// app/sitemap.ts

import type { MetadataRoute } from 'next';

export default function sitemap(): MetadataRoute.Sitemap {
  const baseUrl = 'https://example.com';

  const staticPages: MetadataRoute.Sitemap = [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1.0,
    },
    {
      url: `${baseUrl}/articles`,
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.9,
    },
    {
      url: `${baseUrl}/about`,
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.5,
    },
    {
      url: `${baseUrl}/contact`,
      lastModified: new Date(),
      changeFrequency: 'yearly',
      priority: 0.3,
    },
  ];

  return staticPages;
}

动态 Sitemap(数据库驱动)

// app/sitemap.ts

import type { MetadataRoute } from 'next';
import { prisma } from '@/lib/prisma';

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const baseUrl = 'https://example.com';

  // 静态页面
  const staticPages: MetadataRoute.Sitemap = [
    {
      url: baseUrl,
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 1.0,
    },
    {
      url: `${baseUrl}/articles`,
      lastModified: new Date(),
      changeFrequency: 'daily',
      priority: 0.9,
    },
    {
      url: `${baseUrl}/about`,
      lastModified: new Date(),
      changeFrequency: 'monthly',
      priority: 0.5,
    },
  ];

  // 文章页面
  const articles = await prisma.article.findMany({
    where: { published: true },
    select: {
      slug: true,
      updatedAt: true,
    },
    orderBy: { createdAt: 'desc' },
  });

  const articlePages: MetadataRoute.Sitemap = articles.map((article) => ({
    url: `${baseUrl}/articles/${article.slug}`,
    lastModified: article.updatedAt,
    changeFrequency: 'weekly',
    priority: 0.8,
  }));

  // 分类页面
  const categories = ['frontend', 'backend', 'devops', 'design'];
  const categoryPages: MetadataRoute.Sitemap = categories.map((cat) => ({
    url: `${baseUrl}/articles?category=${cat}`,
    lastModified: new Date(),
    changeFrequency: 'daily',
    priority: 0.7,
  }));

  // 作者页面
  const authors = await prisma.user.findMany({
    where: {
      articles: { some: { published: true } },
    },
    select: { id: true },
  });

  const authorPages: MetadataRoute.Sitemap = authors.map((author) => ({
    url: `${baseUrl}/authors/${author.id}`,
    lastModified: new Date(),
    changeFrequency: 'weekly',
    priority: 0.6,
  }));

  return [
    ...staticPages,
    ...articlePages,
    ...categoryPages,
    ...authorPages,
  ];
}

多 Sitemap(大型站点)

当 URL 数量超过 50,000 时,需要拆分为多个 Sitemap:

// app/sitemap.ts(主 Sitemap Index)

import type { MetadataRoute } from 'next';

export default function sitemap(): MetadataRoute.Sitemap {
  // 主 Sitemap 指向多个子 Sitemap
  return [
    {
      url: 'https://example.com/sitemap-static.xml',
      lastModified: new Date(),
    },
    {
      url: 'https://example.com/sitemap-articles.xml',
      lastModified: new Date(),
    },
    {
      url: 'https://example.com/sitemap-users.xml',
      lastModified: new Date(),
    },
  ];
}
// app/sitemap-articles.xml/route.ts

import { prisma } from '@/lib/prisma';

export async function GET() {
  const articles = await prisma.article.findMany({
    where: { published: true },
    select: { slug: true, updatedAt: true },
  });

  const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  ${articles
    .map(
      (a) => `
  <url>
    <loc>https://example.com/articles/${a.slug}</loc>
    <lastmod>${a.updatedAt.toISOString()}</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.8</priority>
  </url>`
    )
    .join('')}
</urlset>`;

  return new Response(sitemap, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, max-age=3600',
    },
  });
}

18.5 robots.txt 配置

基础 robots.txt

// app/robots.ts

import type { MetadataRoute } from 'next';

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: [
          '/api/',
          '/dashboard/',
          '/admin/',
          '/auth/',
          '/_next/',
          '/private/',
        ],
      },
      {
        userAgent: 'GPTBot',
        disallow: '/',  // 阻止 OpenAI 爬取
      },
      {
        userAgent: 'CCBot',
        disallow: '/',  // 阻止 Common Crawl
      },
    ],
    sitemap: 'https://example.com/sitemap.xml',
    host: 'https://example.com',
  };
}

阻止 AI 爬虫(可选)

// app/robots.ts

export default function robots(): MetadataRoute.Robots {
  // 常见 AI 爬虫 User-Agent
  const aiBots = [
    'GPTBot',            // OpenAI
    'ChatGPT-User',      // OpenAI
    'CCBot',             // Common Crawl
    'anthropic-ai',      // Anthropic
    'Claude-Web',        // Anthropic
    'Google-Extended',   // Google AI
    'Bytespider',        // ByteDance
    'Diffbot',           // Diffbot
    'FacebookBot',       // Meta
    'ImagesiftBot',      // ImageSift
    'Omgilibot',         // Omgili
    'PerplexityBot',     // Perplexity
    'YouBot',            // You.com
  ];

  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: ['/api/', '/dashboard/', '/admin/'],
      },
      // 阻止所有 AI 爬虫
      ...aiBots.map((bot) => ({
        userAgent: bot,
        disallow: '/',
      })),
    ],
    sitemap: 'https://example.com/sitemap.xml',
  };
}

18.6 结构化数据(JSON-LD)

文章页面结构化数据

// app/articles/[slug]/page.tsx

import { getArticle } from '@/lib/services/article';

export default async function ArticlePage({ params }: Props) {
  const article = await getArticle(params.slug);
  if (!article) notFound();

  // Article JSON-LD
  const articleJsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: article.title,
    description: article.excerpt,
    image: article.coverImage,
    datePublished: article.publishedAt?.toISOString(),
    dateModified: article.updatedAt?.toISOString(),
    wordCount: article.content.split(/\s+/).length,
    author: {
      '@type': 'Person',
      name: article.author?.name || 'Unknown',
      url: article.author
        ? `https://example.com/authors/${article.author.id}`
        : undefined,
    },
    publisher: {
      '@type': 'Organization',
      name: 'MyBlog',
      logo: {
        '@type': 'ImageObject',
        url: 'https://example.com/logo.png',
      },
    },
    mainEntityOfPage: {
      '@type': 'WebPage',
      '@id': `https://example.com/articles/${article.slug}`,
    },
    keywords: article.tags?.join(', '),
  };

  // Breadcrumb JSON-LD
  const breadcrumbJsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: [
      {
        '@type': 'ListItem',
        position: 1,
        name: '首页',
        item: 'https://example.com',
      },
      {
        '@type': 'ListItem',
        position: 2,
        name: '文章',
        item: 'https://example.com/articles',
      },
      {
        '@type': 'ListItem',
        position: 3,
        name: article.category || 'Uncategorized',
        item: `https://example.com/articles?category=${article.category}`,
      },
      {
        '@type': 'ListItem',
        position: 4,
        name: article.title,
        item: `https://example.com/articles/${article.slug}`,
      },
    ],
  };

  return (
    <article>
      {/* JSON-LD 结构化数据 */}
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(articleJsonLd) }}
      />
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
      />

      {/* 文章内容 */}
      <header>
        <h1>{article.title}</h1>
        {/* ... */}
      </header>
      <div dangerouslySetInnerHTML={{ __html: article.content }} />
    </article>
  );
}

网站首页结构化数据

// app/page.tsx

export default function HomePage() {
  const websiteJsonLd = {
    '@context': 'https://schema.org',
    '@type': 'WebSite',
    name: 'MyBlog',
    url: 'https://example.com',
    description: '系统学习 Next.js App Router,从入门到生产实战。',
    potentialAction: {
      '@type': 'SearchAction',
      target: {
        '@type': 'EntryPoint',
        urlTemplate: 'https://example.com/articles?q={search_term_string}',
      },
      'query-input': 'required name=search_term_string',
    },
    publisher: {
      '@type': 'Organization',
      name: 'MyBlog',
      logo: {
        '@type': 'ImageObject',
        url: 'https://example.com/logo.png',
        width: 512,
        height: 512,
      },
    },
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(websiteJsonLd) }}
      />

      <main>{/* 首页内容 */}</main>
    </>
  );
}

FAQ 结构化数据

// components/FAQ.tsx

type FAQItem = {
  question: string;
  answer: string;
};

export function FAQ({ items }: { items: FAQItem[] }) {
  const faqJsonLd = {
    '@context': 'https://schema.org',
    '@type': 'FAQPage',
    mainEntity: items.map((item) => ({
      '@type': 'Question',
      name: item.question,
      acceptedAnswer: {
        '@type': 'Answer',
        text: item.answer,
      },
    })),
  };

  return (
    <section>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
      />

      <h2>常见问题</h2>
      {items.map((item, i) => (
        <div key={i}>
          <h3>{item.question}</h3>
          <p>{item.answer}</p>
        </div>
      ))}
    </section>
  );
}

面包屑导航组件

// components/Breadcrumb.tsx

import Link from 'next/link';

type BreadcrumbItem = {
  name: string;
  href?: string;
};

export function Breadcrumb({ items }: { items: BreadcrumbItem[] }) {
  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: items.map((item, i) => ({
      '@type': 'ListItem',
      position: i + 1,
      name: item.name,
      item: item.href ? `https://example.com${item.href}` : undefined,
    })),
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />

      <nav aria-label="Breadcrumb" className="text-sm text-muted-foreground">
        <ol className="flex items-center gap-2">
          {items.map((item, i) => (
            <li key={i} className="flex items-center gap-2">
              {i > 0 && <span>/</span>}
              {item.href ? (
                <Link href={item.href} className="hover:text-foreground">
                  {item.name}
                </Link>
              ) : (
                <span className="text-foreground">{item.name}</span>
              )}
            </li>
          ))}
        </ol>
      </nav>
    </>
  );
}

常用 Schema 类型速查

页面类型Schema 类型
首页WebSite
文章BlogPosting / Article
产品Product + Offer
关于页AboutPage + Organization / Person
FAQFAQPage
教程HowTo
食谱Recipe
活动Event
评价Review + AggregateRating
面包屑BreadcrumbList

验证结构化数据

# Google Rich Results Test
# https://search.google.com/test/rich-results

# Schema Validator
# https://validator.schema.org/

# 在开发环境中打印 JSON-LD 验证
console.log('JSON-LD:', JSON.stringify(jsonLd, null, 2));

18.7 RSS Feed

生成 RSS XML

// app/rss.xml/route.ts

import { prisma } from '@/lib/prisma';

export async function GET() {
  const articles = await prisma.article.findMany({
    where: { published: true },
    orderBy: { publishedAt: 'desc' },
    take: 20,
    include: {
      author: { select: { name: true, email: true } },
    },
  });

  const rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"
  xmlns:atom="http://www.w3.org/2005/Atom"
  xmlns:dc="http://purl.org/dc/elements/1.1/"
  xmlns:content="http://purl.org/rss/1.0/modules/content/">
  <channel>
    <title>MyBlog</title>
    <link>https://example.com</link>
    <description>系统学习 Next.js App Router</description>
    <language>zh-CN</language>
    <lastBuildDate>${new Date().toUTCString()}</lastBuildDate>
    <atom:link href="https://example.com/rss.xml" rel="self" type="application/rss+xml"/>
    <image>
      <url>https://example.com/logo.png</url>
      <title>MyBlog</title>
      <link>https://example.com</link>
    </image>
    ${articles
      .map(
        (article) => `
    <item>
      <title><![CDATA[${article.title}]]></title>
      <link>https://example.com/articles/${article.slug}</link>
      <guid isPermaLink="true">https://example.com/articles/${article.slug}</guid>
      <description><![CDATA[${article.excerpt || ''}]]></description>
      <pubDate>${article.publishedAt?.toUTCString() || ''}</pubDate>
      <dc:creator>${article.author?.name || 'Unknown'}</dc:creator>
      ${article.tags?.map((tag) => `<category>${tag}</category>`).join('\n      ') || ''}
    </item>`
      )
      .join('')}
  </channel>
</rss>`;

  return new Response(rss, {
    headers: {
      'Content-Type': 'application/xml; charset=utf-8',
      'Cache-Control': 'public, max-age=3600, s-maxage=3600',
    },
  });
}

在 Metadata 中声明

// app/layout.tsx

export const metadata: Metadata = {
  alternates: {
    types: {
      'application/rss+xml': '/rss.xml',
    },
  },
};

18.8 国际化 SEO(hreflang)

hreflang 标签

// app/articles/[slug]/page.tsx

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const article = await getArticle(params.slug);

  return {
    title: article.title,
    alternates: {
      canonical: `https://example.com/zh/articles/${article.slug}`,
      languages: {
        'zh-CN': `https://example.com/zh/articles/${article.slug}`,
        'en-US': `https://example.com/en/articles/${article.slug}`,
        'ja-JP': `https://example.com/ja/articles/${article.slug}`,
        'x-default': `https://example.com/en/articles/${article.slug}`,
      },
    },
  };
}

在 Middleware 中处理

// middleware.ts

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

const locales = ['en', 'zh', 'ja'];
const defaultLocale = 'en';

export function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // 检查是否已有语言前缀
  const hasLocale = locales.some(
    (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
  );

  if (!hasLocale) {
    // 检测用户语言偏好
    const acceptLanguage = request.headers.get('accept-language') || '';
    const preferred = acceptLanguage.split(',')[0]?.split('-')[0] || defaultLocale;
    const locale = locales.includes(preferred) ? preferred : defaultLocale;

    return NextResponse.redirect(
      new URL(`/${locale}${pathname}`, request.url)
    );
  }

  return NextResponse.next();
}

18.9 Canonical URL

避免重复内容

// 每篇文章都设置 canonical URL
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  return {
    alternates: {
      canonical: `https://example.com/articles/${params.slug}`,
    },
  };
}

// 列表页(带筛选参数时)
export async function generateMetadata({ searchParams }: Props): Promise<Metadata> {
  // 不同筛选参数指向同一个 canonical
  return {
    alternates: {
      canonical: 'https://example.com/articles',
    },
  };
}

避免常见的重复内容陷阱

❌ 不要:
  example.com/articles
  example.com/articles/
  www.example.com/articles
  example.com/articles?sort=new
  example.com/articles?page=1

✅ 要:
  所有变体都 canonical 到 example.com/articles
  使用 301 重定向统一 URL

18.10 搜索引擎提交与监控

Google Search Console

// app/layout.tsx

export const metadata: Metadata = {
  verification: {
    google: 'your-google-verification-code',
    yandex: 'your-yandex-verification-code',
    other: {
      'msvalidate.01': 'your-bing-verification-code',
    },
  },
};

自动 Ping 搜索引擎

// lib/seo/ping.ts

export async function pingSearchEngines(url: string) {
  const endpoints = [
    `https://www.google.com/ping?sitemap=${encodeURIComponent(url)}`,
    `https://www.bing.com/ping?sitemap=${encodeURIComponent(url)}`,
  ];

  const results = await Promise.allSettled(
    endpoints.map((endpoint) => fetch(endpoint, { method: 'GET' }))
  );

  return results;
}
// app/actions/article.ts
'use server';

import { revalidatePath } from 'next/cache';
import { pingSearchEngines } from '@/lib/seo/ping';

export async function createArticle(formData: FormData) {
  // ... 创建文章

  revalidatePath('/articles');

  // 通知搜索引擎 sitemap 已更新
  await pingSearchEngines('https://example.com/sitemap.xml');
}

监控 SEO 指标

// lib/seo/monitor.ts

// 使用 web-vitals + Google Analytics 监控 SEO 相关指标
import { onCLS, onINP, onLCP } from 'web-vitals';

export function reportSEOMetrics() {
  onLCP((metric) => {
    gtag('event', 'web_vitals', {
      event_category: 'Web Vitals',
      event_action: 'LCP',
      event_value: Math.round(metric.value),
      event_label: metric.rating, // good / needs-improvement / poor
      non_interaction: true,
    });
  });

  onINP((metric) => {
    gtag('event', 'web_vitals', {
      event_category: 'Web Vitals',
      event_action: 'INP',
      event_value: Math.round(metric.value),
      event_label: metric.rating,
      non_interaction: true,
    });
  });

  onCLS((metric) => {
    gtag('event', 'web_vitals', {
      event_category: 'Web Vitals',
      event_action: 'CLS',
      event_value: Math.round(metric.value * 1000) / 1000,
      event_label: metric.rating,
      non_interaction: true,
    });
  });
}

18.11 SEO 审计清单

## SEO 完整审计清单

### 基础 SEO
- [ ] 每个页面都有唯一的 title 和 description
- [ ] title 使用模板化(%s | 站点名)
- [ ] description 长度在 150-160 字符之间
- [ ] 所有页面使用 HTTPS
- [ ] 移动端友好(响应式设计)

### Metadata
- [ ] 设置了 metadataBase
- [ ] 配置了 openGraph(type、title、description、images)
- [ ] 配置了 twitter card
- [ ] 设置了 canonical URL
- [ ] 配置了 robots 规则
- [ ] 设置了 favicons(多尺寸)

### 结构化数据
- [ ] 首页使用 WebSite Schema
- [ ] 文章页使用 BlogPosting Schema
- [ ] 面包屑使用 BreadcrumbList Schema
- [ ] FAQ 使用 FAQPage Schema
- [ ] 使用 Rich Results Test 验证

### 爬虫友好
- [ ] robots.txt 配置正确
- [ ] sitemap.xml 自动生成且包含所有页面
- [ ] 已提交到 Google Search Console
- [ ] 已提交到 Bing Webmaster Tools
- [ ] RSS Feed 可访问

### 国际化(如适用)
- [ ] 配置了 hreflang 标签
- [ ] 每种语言有独立的 URL
- [ ] 设置了 x-default 指向默认语言

### 性能(影响 SEO 排名)
- [ ] LCP < 2.5s
- [ ] INP < 200ms
- [ ] CLS < 0.1
- [ ] 图片使用 next/image
- [ ] 字体使用 next/font

### 内容质量
- [ ] 内容原创、有深度
- [ ] 标题包含目标关键词
- [ ] URL 简洁、语义化
- [ ] 内部链接结构清晰
- [ ] 图片有 alt 属性

本章小结

Key Takeaways

  1. Metadata API 是 Next.js SEO 的核心:静态、动态、模板化三种模式灵活使用
  2. @vercel/og 让每篇文章都有独特封面:动态生成 OpenGraph 图片
  3. Sitemap 和 robots.txt 是爬虫友好的基础:内置 sitemap.tsrobots.ts
  4. JSON-LD 结构化数据提升富摘要展示:BlogPosting、BreadcrumbList、FAQPage
  5. hreflang 是国际化 SEO 的关键:每种语言一个 URL + hreflang 标签
  6. 持续监控是 SEO 成功的前提:Google Search Console + web-vitals

下一步

下一章我们将深入 测试体系——建立 Next.js 的完整测试策略,包括 Vitest 单元测试、React Testing Library 组件测试、Playwright E2E 测试。


参考资料

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页