第 19 章

Metadata API:SEO 与社交分享最佳实践

第19章:Metadata API:SEO 与社交分享最佳实践

Next.js 的 Metadata API 将 SEO 从事后补救提升为架构设计的一部分——所有元数据在服务端生成,对爬虫完全可见。

本章核心问题:Metadata 的继承与覆盖机制如何工作?如何生成动态 OG 图片?如何配置 sitemap 和结构化数据?

读完本章你将理解


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.txtsitemap.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 和社交分享解决方案,且完全在服务端生成,对爬虫完全可见。

本章评分
4.6  / 5  (10 评分)

💬 留言讨论