Metadata API:SEO 与社交分享最佳实践
第19章:Metadata API:SEO 与社交分享最佳实践
Next.js 的 Metadata API 将 SEO 从事后补救提升为架构设计的一部分——所有元数据在服务端生成,对爬虫完全可见。
本章核心问题:Metadata 的继承与覆盖机制如何工作?如何生成动态 OG 图片?如何配置 sitemap 和结构化数据?
读完本章你将理解:
- metadata 对象的继承链与 title.template 机制
- generateMetadata 异步生成元数据与数据请求去重
- ImageResponse 动态 OG 图片与 JSON-LD 结构化数据
Level 1 · 你需要知道的(1-3年经验)
为什么 Metadata 需要在服务端生成
传统 SPA 时代,<title> 和 <meta> 标签经常通过客户端 JavaScript 动态设置。这种方式的问题在于:搜索引擎爬虫(尤其是非 Google 的爬虫)和社交媒体爬虫(Twitter、Facebook、微信等)大多不执行 JavaScript,它们只读取原始 HTML。如果页面标题和描述在 JavaScript 执行后才出现,这些平台看到的页面就是空的或只有通用内容。
Next.js App Router 的 Metadata API 在服务端生成所有元数据并注入到 HTML <head> 中,保证爬虫第一时间看到完整、正确的元数据。
静态 Metadata:export const metadata
最简单的方式是从页面文件或布局文件中导出 metadata 对象:
// app/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'YiteAI 工具集 - 智能 AI 工具平台',
description: '面向出海企业的一站式 AI 工具平台,提供市场分析、内容翻译、SEO 优化等专业工具。',
keywords: ['AI工具', '出海', '市场分析', 'SEO优化'],
authors: [{ name: 'YiteAI Team', url: 'https://dev.yiteai.com' }],
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
}
Metadata 继承与覆盖机制
Next.js 的 Metadata 采用级联继承模型:根布局(app/layout.tsx)定义基础元数据,子布局和页面可以选择性覆盖。未覆盖的字段从父级继承。
// app/layout.tsx - 根布局的全局默认 metadata
export const metadata: Metadata = {
title: {
default: 'YiteAI', // 未设置 title 时的默认值
template: '%s | YiteAI', // 子页面 title 的模板
},
description: 'AI 工具平台',
metadataBase: new URL('https://dev.yiteai.com'), // 相对 URL 的基准
}
// app/tools/seo/page.tsx - 子页面只需设置差异部分
export const metadata: Metadata = {
title: 'SEO 分析工具', // 最终生成:SEO 分析工具 | YiteAI
description: '深度分析目标市场 SEO 机会,生成关键词报告与内容建议。',
// 其他字段继承自根布局
}
title.template 中的 %s 会被子页面的 title 字符串替换。这个机制确保所有页面的标题格式一致,同时让子页面只需关心自己的核心标题,不必重复品牌名。
metadataBase 是一个重要但常被忽略的配置项。Open Graph 图片、canonical URL 等需要绝对 URL 的地方,Next.js 会将相对路径拼接到 metadataBase 上。不设置 metadataBase 会导致开发环境生成 http://localhost:3000 开头的 URL,被分享到社交媒体后图片无法显示。
Level 2 · 它是怎么运行的(3-5年经验)
Open Graph:社交分享卡片
Open Graph 协议(由 Facebook 提出)是社交媒体分享预览的标准。Twitter/X、LinkedIn、Slack、微信等平台都支持 OG 标签:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { getPost } from '@/lib/blog'
interface Props {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt.toISOString(),
authors: [post.author.name],
images: [
{
url: post.coverImage, // 相对路径会自动拼接 metadataBase
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
creator: '@yiteai',
},
}
}
OG 图片的标准尺寸是 1200x630 像素(宽高比 1.91:1)。twitter:card 的值影响 Twitter 展示方式:summary 显示小图,summary_large_image 显示大横幅图片,点击率通常高 30% 左右。
generateMetadata:动态生成元数据
对于依赖数据库查询或外部 API 的元数据,使用异步 generateMetadata 函数:
// app/products/[id]/page.tsx
import type { Metadata } from 'next'
import { getProduct, getRelatedProducts } from '@/lib/products'
import { notFound } from 'next/navigation'
interface Props {
params: Promise<{ id: string }>
searchParams: Promise<{ variant?: string }>
}
export async function generateMetadata(
{ params, searchParams }: Props
): Promise<Metadata> {
const { id } = await params
const { variant } = await searchParams
const product = await getProduct(id)
if (!product) {
return {
title: '商品不存在',
description: '您访问的商品已下架或不存在。',
}
}
const variantInfo = variant
? product.variants.find(v => v.id === variant)
: product.variants[0]
const imageUrl = variantInfo?.image ?? product.defaultImage
return {
title: product.name,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
type: 'website',
images: [{ url: imageUrl, width: 800, height: 800, alt: product.name }],
},
// 结构化数据中的产品价格信息
other: {
'product:price:amount': String(product.price),
'product:price:currency': 'CNY',
},
}
}
Next.js 会自动对 generateMetadata 中的数据请求去重——如果 generateMetadata 和页面组件调用了相同的 getProduct(id) 函数,实际只会执行一次数据库查询(通过 React 的 cache() 函数实现)。
Level 3 · 规范怎么定义的(资深)
动态 OG 图片:next/og ImageResponse
静态 OG 图片无法动态展示内容(如文章标题、作者名)。next/og 提供 ImageResponse,让你用 JSX 定义图片内容,在运行时渲染为真实图片:
// app/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 } = request.nextUrl
const title = searchParams.get('title') ?? 'YiteAI'
const description = searchParams.get('desc') ?? ''
const author = searchParams.get('author') ?? 'YiteAI Team'
// 在 Edge Runtime 中加载字体
const fontData = await fetch(
new URL('../../../public/fonts/NotoSansSC-Bold.ttf', import.meta.url)
).then(res => res.arrayBuffer())
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '1200px',
height: '630px',
backgroundColor: '#0f172a',
padding: '80px',
justifyContent: 'space-between',
}}
>
{/* Logo 区域 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{
width: 48, height: 48,
backgroundColor: '#6366f1',
borderRadius: 12,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<span style={{ color: 'white', fontSize: 24, fontWeight: 700 }}>Y</span>
</div>
<span style={{ color: '#94a3b8', fontSize: 24 }}>YiteAI</span>
</div>
{/* 标题 */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<h1 style={{
color: 'white',
fontSize: title.length > 30 ? 48 : 64,
fontWeight: 700,
lineHeight: 1.2,
margin: 0,
}}>
{title}
</h1>
{description && (
<p style={{ color: '#94a3b8', fontSize: 28, margin: 0 }}>
{description}
</p>
)}
</div>
{/* 底部作者信息 */}
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ color: '#64748b', fontSize: 20 }}>{author}</span>
</div>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'NotoSansSC',
data: fontData,
weight: 700,
style: 'normal',
},
],
}
)
}
在页面的 generateMetadata 中引用这个动态 OG 图片:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
const ogImageUrl = new URL('/og', 'https://dev.yiteai.com')
ogImageUrl.searchParams.set('title', post.title)
ogImageUrl.searchParams.set('desc', post.excerpt)
ogImageUrl.searchParams.set('author', post.author.name)
return {
title: post.title,
openGraph: {
images: [{ url: ogImageUrl.toString(), width: 1200, height: 630 }],
},
}
}
ImageResponse 在 Edge Runtime 中运行,不需要 canvas 或 Puppeteer,使用 Satori 库将 React 样式转换为 SVG,再用 resvg 转换为 PNG。渲染时间通常在 50-200ms,适合按需生成。
Robots.txt 与 Sitemap:两种实现方式
方式一:静态文件
将 robots.txt 和 sitemap.xml 放在 public/ 目录下,适合不需要动态生成的情况:
public/
robots.txt
sitemap.xml
方式二:Route Handler 动态生成
对于需要动态内容(如从数据库获取 URL 列表)的情况,使用 Route Handler:
// app/sitemap.xml/route.ts
import { getAllPostSlugs, getAllProductIds } from '@/lib/db'
export async function GET() {
const [posts, products] = await Promise.all([
getAllPostSlugs(),
getAllProductIds(),
])
const baseUrl = 'https://dev.yiteai.com'
const staticUrls = [
{ loc: baseUrl, priority: '1.0', changefreq: 'daily' },
{ loc: `${baseUrl}/tools`, priority: '0.9', changefreq: 'weekly' },
{ loc: `${baseUrl}/blog`, priority: '0.8', changefreq: 'daily' },
]
const postUrls = posts.map(slug => ({
loc: `${baseUrl}/blog/${slug}`,
priority: '0.7',
changefreq: 'monthly',
lastmod: new Date().toISOString(),
}))
const allUrls = [...staticUrls, ...postUrls]
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${allUrls.map(url => ` <url>
<loc>${url.loc}</loc>
<priority>${url.priority}</priority>
<changefreq>${url.changefreq}</changefreq>
${url.lastmod ? `<lastmod>${url.lastmod}</lastmod>` : ''}
</url>`).join('\n')}
</urlset>`
return new Response(xml, {
headers: {
'Content-Type': 'application/xml',
'Cache-Control': 'public, max-age=3600, s-maxage=3600',
},
})
}
方式三:Next.js 原生 Sitemap 配置
Next.js 15 支持通过 app/sitemap.ts 导出函数来生成 sitemap:
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/blog'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
const postUrls: MetadataRoute.Sitemap = posts.map(post => ({
url: `https://dev.yiteai.com/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'monthly',
priority: 0.7,
}))
return [
{
url: 'https://dev.yiteai.com',
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
...postUrls,
]
}
类似地,app/robots.ts 生成 robots.txt:
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', '/_next/'],
},
],
sitemap: 'https://dev.yiteai.com/sitemap.xml',
}
}
Canonical URL:避免重复内容惩罚
当同一内容可以通过多个 URL 访问时(如 /products/shoe 和 /products/shoe?color=red),需要指定 canonical URL 告诉搜索引擎哪个是权威 URL:
export async function generateMetadata({ params, searchParams }: Props): Promise<Metadata> {
const { id } = await params
// canonical 始终指向不带查询参数的基础 URL
return {
alternates: {
canonical: `https://dev.yiteai.com/products/${id}`,
languages: {
'zh-CN': `https://dev.yiteai.com/zh/products/${id}`,
'en-US': `https://dev.yiteai.com/en/products/${id}`,
},
},
}
}
JSON-LD 结构化数据
结构化数据帮助搜索引擎理解页面内容,可以触发富结果(Rich Results),如星评、价格、问答框等。在 Server Component 中注入 JSON-LD 最为简单:
// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/blog'
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
author: {
'@type': 'Person',
name: post.author.name,
url: `https://dev.yiteai.com/authors/${post.author.slug}`,
},
datePublished: post.publishedAt.toISOString(),
dateModified: post.updatedAt.toISOString(),
image: post.coverImage,
publisher: {
'@type': 'Organization',
name: 'YiteAI',
logo: {
'@type': 'ImageObject',
url: 'https://dev.yiteai.com/logo.png',
},
},
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>
<h1>{post.title}</h1>
{/* ... */}
</article>
</>
)
}
使用 dangerouslySetInnerHTML 注入 JSON-LD 时,数据来源是服务端可信数据,不存在 XSS 风险。但如果有用户输入的内容进入 JSON-LD,务必使用 JSON.stringify 的转义机制确保安全。
Level 4 · 边界与陷阱(所有人)
陷阱1:不设置 metadataBase 会导致开发环境生成 http://localhost:3000 开头的 URL——分享到社交媒体后图片无法显示。
陷阱2:OG 图片标准尺寸是 1200x630 像素——非标准尺寸在某些平台上会被裁剪或拉伸。
陷阱3:dangerouslySetInnerHTML 注入 JSON-LD 时,数据来源是服务端可信数据不存在 XSS 风险——但如果有用户输入内容,务必用 JSON.stringify 转义。
小结
Next.js 的 Metadata API 将 SEO 从事后补救提升为架构设计的一部分。metadata 对象的继承链让全站元数据保持一致格式,generateMetadata 确保动态内容的元数据与页面内容同步,ImageResponse 让每篇文章都有精美的动态分享卡片,结构化数据则开启了富结果的大门。这些能力组合起来,构成了一个完整的 SEO 和社交分享解决方案,且完全在服务端生成,对爬虫完全可见。