Skip to content

元数据与 SEO

🙋 网站在搜索结果中表现不佳?正确配置元数据是 SEO 的基础。

Metadata API 概述#

Next.js 提供两种配置元数据的方式:

方式适用场景
静态 metadata 对象固定内容
generateMetadata 函数动态内容

静态元数据#

基础配置#

app/layout.tsx
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>
)
}

完整配置#

app/layout.tsx
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: '技术',
}

页面级元数据#

app/about/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: '关于我们', // 使用模板:关于我们 | 我的网站
description: '了解更多关于我们的信息',
}
export default function AboutPage() {
return <div>关于页面</div>
}

动态元数据#

generateMetadata 函数#

app/blog/[slug]/page.tsx
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 优化#

文章类型#

app/blog/[slug]/page.tsx
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,
},
}
}

产品类型#

app/products/[id]/page.tsx
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#

app/blog/[slug]/page.tsx
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>
</>
)
}

组织结构化数据#

app/layout.tsx
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>
)
}

站点地图#

静态站点地图#

app/sitemap.ts
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,
},
]
}

动态站点地图#

app/sitemap.ts
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,
]
}

多站点地图#

app/sitemap.ts
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#

app/robots.ts
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#

app/api/og/route.tsx
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,
}
)
}

在元数据中使用#

app/blog/[slug]/page.tsx
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,
},
],
},
}
}

自定义字体#

app/api/og/route.tsx
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>
)
}

面包屑导航#

components/Breadcrumb.tsx
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 和布局层级:

app/layout.tsx
export const metadata: Metadata = {
title: {
template: '%s | 我的网站',
default: '我的网站',
},
}
// app/blog/page.tsx
export const metadata: Metadata = {
title: '博客', // 输出:博客 | 我的网站
}

🤔 Q: 动态元数据请求会重复吗?

不会。Next.js 自动去重相同的 fetch 请求。

🤔 Q: 如何验证元数据?

使用在线工具:


下一篇将介绍性能分析,学习如何分析和优化应用性能。

-EOF-