本章目标:构建一套完整的 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 |
| FAQ | FAQPage |
| 教程 | 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
- Metadata API 是 Next.js SEO 的核心:静态、动态、模板化三种模式灵活使用
- @vercel/og 让每篇文章都有独特封面:动态生成 OpenGraph 图片
- Sitemap 和 robots.txt 是爬虫友好的基础:内置
sitemap.ts和robots.ts - JSON-LD 结构化数据提升富摘要展示:BlogPosting、BreadcrumbList、FAQPage
- hreflang 是国际化 SEO 的关键:每种语言一个 URL + hreflang 标签
- 持续监控是 SEO 成功的前提:Google Search Console + web-vitals
下一步
下一章我们将深入 测试体系——建立 Next.js 的完整测试策略,包括 Vitest 单元测试、React Testing Library 组件测试、Playwright E2E 测试。
参考资料
- Next.js Metadata API 官方文档
- Next.js SEO 指南
- @vercel/og
- Schema.org
- Google Rich Results Test
- Google Search Console
- Sitemaps.org 协议
- hreflang 指南
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。