🙋 网站在搜索结果中表现不佳?正确配置元数据是 SEO 的基础。
Metadata API 概述#
Next.js 提供两种配置元数据的方式:
| 方式 | 适用场景 |
|---|---|
| 静态 metadata 对象 | 固定内容 |
| generateMetadata 函数 | 动态内容 |
静态元数据#
基础配置#
import type { Metadata } from 'next'
export const metadata: Metadata = { title: '我的网站', description: '这是一个使用 Next.js 构建的网站',}
export default function RootLayout({ children,}: { children: React.ReactNode}) { return ( <html lang="zh-CN"> <body>{children}</body> </html> )}完整配置#
import type { Metadata } from 'next'
export const metadata: Metadata = { // 基础 title: { default: '我的网站', template: '%s | 我的网站', // 子页面标题模板 }, description: '网站描述,用于搜索结果展示', keywords: ['Next.js', 'React', 'JavaScript'], authors: [{ name: '作者名', url: 'https://example.com' }], creator: '创建者', publisher: '发布者',
// 图标 icons: { icon: '/favicon.ico', shortcut: '/favicon-16x16.png', apple: '/apple-touch-icon.png', },
// 清单 manifest: '/manifest.json',
// Open Graph openGraph: { title: '我的网站', description: '网站描述', url: 'https://example.com', siteName: '我的网站', images: [ { url: 'https://example.com/og.jpg', width: 1200, height: 630, alt: '网站预览图', }, ], locale: 'zh_CN', type: 'website', },
// Twitter twitter: { card: 'summary_large_image', title: '我的网站', description: '网站描述', images: ['https://example.com/og.jpg'], creator: '@username', },
// 机器人 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', },
// 其他 alternates: { canonical: 'https://example.com', languages: { 'en-US': 'https://example.com/en', 'zh-CN': 'https://example.com/zh', }, }, category: '技术',}页面级元数据#
import type { Metadata } from 'next'
export const metadata: Metadata = { title: '关于我们', // 使用模板:关于我们 | 我的网站 description: '了解更多关于我们的信息',}
export default function AboutPage() { return <div>关于页面</div>}动态元数据#
generateMetadata 函数#
import type { Metadata } from 'next'
interface Props { params: Promise<{ slug: string }>}
async function getPost(slug: string) { const res = await fetch(`https://api.example.com/posts/${slug}`) return res.json()}
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, images: [post.coverImage], type: 'article', publishedTime: post.publishedAt, authors: [post.author.name], }, }}
export default async function BlogPost({ params }: Props) { const { slug } = await params const post = await getPost(slug)
return <article>{post.content}</article>}访问父级元数据#
export async function generateMetadata( { params }: Props, parent: ResolvingMetadata): Promise<Metadata> { const { slug } = await params const post = await getPost(slug)
// 获取父级元数据 const previousImages = (await parent).openGraph?.images || []
return { title: post.title, openGraph: { images: [post.coverImage, ...previousImages], }, }}Open Graph 优化#
文章类型#
export async function generateMetadata({ params }: Props): Promise<Metadata> { const { slug } = await params const post = await getPost(slug)
return { openGraph: { type: 'article', title: post.title, description: post.excerpt, url: `https://example.com/blog/${slug}`, images: [ { url: post.coverImage, width: 1200, height: 630, alt: post.title, }, ], publishedTime: post.publishedAt, modifiedTime: post.updatedAt, authors: [post.author.name], section: post.category, tags: post.tags, }, }}产品类型#
export async function generateMetadata({ params }: Props): Promise<Metadata> { const { id } = await params const product = await getProduct(id)
return { title: product.name, description: product.description, openGraph: { type: 'website', title: product.name, description: product.description, images: product.images.map((img) => ({ url: img.url, width: 1200, height: 630, alt: img.alt, })), }, other: { 'product:price:amount': product.price.toString(), 'product:price:currency': 'CNY', }, }}Twitter Cards#
export const metadata: Metadata = { twitter: { card: 'summary_large_image', // summary, summary_large_image, app, player site: '@site_username', creator: '@creator_username', title: '标题', description: '描述', images: { url: 'https://example.com/og.jpg', alt: '图片描述', }, },}结构化数据#
JSON-LD#
import Script from 'next/script'
export default async function BlogPost({ params }: Props) { const { slug } = await params const post = await getPost(slug)
const jsonLd = { '@context': 'https://schema.org', '@type': 'BlogPosting', 'headline': post.title, 'description': post.excerpt, 'image': post.coverImage, 'datePublished': post.publishedAt, 'dateModified': post.updatedAt, 'author': { '@type': 'Person', 'name': post.author.name, 'url': post.author.url, }, 'publisher': { '@type': 'Organization', 'name': '我的网站', 'logo': { '@type': 'ImageObject', 'url': 'https://example.com/logo.png', }, }, }
return ( <> <Script id="json-ld" type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> <article>{post.content}</article> </> )}组织结构化数据#
import Script from 'next/script'
const organizationJsonLd = { '@context': 'https://schema.org', '@type': 'Organization', 'name': '公司名称', 'url': 'https://example.com', 'logo': 'https://example.com/logo.png', 'sameAs': ['https://twitter.com/company', 'https://github.com/company'], 'contactPoint': { '@type': 'ContactPoint', 'telephone': '+86-xxx-xxxx-xxxx', 'contactType': 'customer service', },}
export default function RootLayout({ children,}: { children: React.ReactNode}) { return ( <html lang="zh-CN"> <body> <Script id="organization-jsonld" type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd), }} /> {children} </body> </html> )}站点地图#
静态站点地图#
import type { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap { return [ { url: 'https://example.com', lastModified: new Date(), changeFrequency: 'daily', priority: 1, }, { url: 'https://example.com/about', lastModified: new Date(), changeFrequency: 'monthly', priority: 0.8, }, { url: 'https://example.com/blog', lastModified: new Date(), changeFrequency: 'weekly', priority: 0.9, }, ]}动态站点地图#
import type { MetadataRoute } from 'next'
async function getPosts() { const res = await fetch('https://api.example.com/posts') return res.json()}
export default async function sitemap(): Promise<MetadataRoute.Sitemap> { const posts = await getPosts()
const blogUrls = posts.map((post: any) => ({ url: `https://example.com/blog/${post.slug}`, lastModified: new Date(post.updatedAt), changeFrequency: 'weekly' as const, priority: 0.7, }))
return [ { url: 'https://example.com', lastModified: new Date(), changeFrequency: 'daily', priority: 1, }, ...blogUrls, ]}多站点地图#
import type { MetadataRoute } from 'next'
export async function generateSitemaps() { // 返回站点地图索引 return [{ id: 0 }, { id: 1 }, { id: 2 }]}
export default async function sitemap({ id,}: { id: number}): Promise<MetadataRoute.Sitemap> { const start = id * 50000 const end = start + 50000
const posts = await getPostsRange(start, end)
return posts.map((post) => ({ url: `https://example.com/blog/${post.slug}`, lastModified: post.updatedAt, }))}robots.txt#
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots { return { rules: [ { userAgent: '*', allow: '/', disallow: ['/api/', '/admin/', '/private/'], }, { userAgent: 'Googlebot', allow: '/', }, ], sitemap: 'https://example.com/sitemap.xml', }}动态 OG 图片#
使用 ImageResponse#
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export async function GET(request: Request) { const { searchParams } = new URL(request.url) const title = searchParams.get('title') || '默认标题'
return new ImageResponse( ( <div style={{ height: '100%', width: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', backgroundColor: '#fff', fontSize: 60, fontWeight: 700, }} > <div style={{ marginBottom: 40 }}>我的网站</div> <div style={{ fontSize: 40, color: '#666' }}>{title}</div> </div> ), { width: 1200, height: 630, } )}在元数据中使用#
export async function generateMetadata({ params }: Props): Promise<Metadata> { const { slug } = await params const post = await getPost(slug)
return { title: post.title, openGraph: { images: [ { url: `/api/og?title=${encodeURIComponent(post.title)}`, width: 1200, height: 630, }, ], }, }}自定义字体#
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export async function GET(request: Request) { const font = await fetch( new URL('../../assets/fonts/NotoSansSC-Bold.ttf', import.meta.url) ).then((res) => res.arrayBuffer())
return new ImageResponse( ( <div style={{ fontFamily: 'Noto Sans SC', // ... }} > 中文标题 </div> ), { width: 1200, height: 630, fonts: [ { name: 'Noto Sans SC', data: font, style: 'normal', weight: 700, }, ], } )}SEO 最佳实践#
语义化 HTML#
export default function BlogPost({ post }: { post: Post }) { return ( <article> <header> <h1>{post.title}</h1> <time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time> </header>
<main>{post.content}</main>
<footer> <address> 作者: <a rel="author" href={post.author.url}> {post.author.name} </a> </address> </footer> </article> )}面包屑导航#
import Link from 'next/link'import Script from 'next/script'
interface BreadcrumbItem { name: string href: string}
export function Breadcrumb({ items }: { items: BreadcrumbItem[] }) { const jsonLd = { '@context': 'https://schema.org', '@type': 'BreadcrumbList', 'itemListElement': items.map((item, index) => ({ '@type': 'ListItem', 'position': index + 1, 'name': item.name, 'item': `https://example.com${item.href}`, })), }
return ( <> <Script id="breadcrumb-jsonld" type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> <nav aria-label="面包屑"> <ol className="flex gap-2"> {items.map((item, index) => ( <li key={item.href} className="flex items-center gap-2"> {index > 0 && <span>/</span>} <Link href={item.href}>{item.name}</Link> </li> ))} </ol> </nav> </> )}常见问题#
🤔 Q: 如何避免重复的元数据?
使用 title.template 和布局层级:
export const metadata: Metadata = { title: { template: '%s | 我的网站', default: '我的网站', },}
// app/blog/page.tsxexport const metadata: Metadata = { title: '博客', // 输出:博客 | 我的网站}🤔 Q: 动态元数据请求会重复吗?
不会。Next.js 自动去重相同的 fetch 请求。
🤔 Q: 如何验证元数据?
使用在线工具:
- Google Rich Results Test
- Facebook Sharing Debugger
- Twitter Card Validator
下一篇将介绍性能分析,学习如何分析和优化应用性能。
-EOF-