发布日期

第 14 讲|元数据、OG image、字体、静态资源

Metadata API、OG image 生成、next/font 零布局偏移与静态资源处理

阶段二:App Router 核心机制 · 第 14 / 40 讲(阶段二收官) 难度:⭐⭐⭐ · 预计耗时:3 小时 配套 fixture:fixtures/lecture-14/

学习目标

  • 看懂 metadata 静态导出 + generateMetadata 动态生成的执行模型与合并规则。
  • 掌握"约定路由"对元数据的特殊处理:favicon.ico icon.png opengraph-image.tsx robots.txt sitemap.xml 等。
  • next/ogImageResponse 动态生成 OG 图,并在 fixture 里调试 fallback 与字体。
  • 弄懂 next/font 自托管模型:构建期下载 / 注入 CSS / 子集 / 字体度量替换。
  • 区分静态资源的 4 种放置位置(public/ / app/ / 模块 import / 远程 CDN),各自的优劣与缓存语义。

1. metadata 与 generateMetadata 概览

App Router 的元数据 API 有两条路径:

// 路径 A:静态导出
export const metadata: Metadata = {
  title: "My Site",
  description: "Welcome",
};

// 路径 B:动态生成
export async function generateMetadata({ params }, parent) {
  const { id } = await params;
  const product = await fetchProduct(id);
  const previous = await parent;
  return {
    title: `${product.name} | ${previous.title?.absolute ?? "Shop"}`,
    description: product.summary,
    openGraph: { images: [product.cover] },
  };
}

二者互斥——同一个 segment 不能同时导出两者。

1.1 三种 title 形态

// 1. 简单字符串
title: 'My Blog'

// 2. 默认 + 模板
title: { default: 'Dashboard', template: '%s | My Website' }

// 3. 绝对值(不被父 template 包裹)
title: { absolute: 'Custom Title', template: '%s | My Website' }

template 在父 layout 设置,所有子 segment 的 title 自动套上。absolute 用于"我就是要这个原文"的页面(如登录页、错误页)。

1.2 metadata 在 LoaderTree 中的合并

第 8 讲讲过 LoaderTree 是从 root 向下递归装配。metadata 也按这个顺序合并:

RootLayout.metadata
GroupLayout.metadata
PageLayout.metadata(如有)
Page.metadata / generateMetadata

合并规则:

  • title:子 template 接收父 title。
  • openGraph / twitter:子完全覆盖父(不深合并)。
  • alternates / icons / robots:子完全覆盖父。
  • metadataBase:通常只在 root 设置一次,子继承。

1.3 parent 参数的妙用

export async function generateMetadata({ params }, parent) {
  const previous = await parent;
  return {
    title: `${product.name}`,
    openGraph: {
      ...(await parent).openGraph, // 继承父 OG
      images: [product.cover],
    },
  };
}

parentPromise<ResolvedMetadata>,能拿到所有"上层已合并"的元数据,再做局部合并。这种方式适合:

  • 商品页继承"店铺"OG 配置,只覆盖图片。
  • 文章页继承博客 default OG,再加章节 title。

1.4 dynamic vs static 影响

export const dynamic = "force-dynamic";
export async function generateMetadata() {
  // 这个函数会在每次请求都重跑
}

如果 page 是动态的(用了 cookies / dynamic = 'force-dynamic')→ generateMetadata 也是动态的,每次请求重跑。 如果 page 是静态的 → generateMetadata 在 build / ISR 时跑一次,结果嵌进 ③ Full Route Cache(第 13 讲)。

生产排查提示:用户报"修改后 OG image 没刷新"——多半是 page 是 static 的,metadata 也走 ③ 缓存。需要 revalidatePathrevalidateTag

2. 约定路由:metadata 文件

App Router 把"放对位置的文件"自动当成元数据路由处理。看源码定义:

27:packages/next/src/lib/metadata/is-metadata-route.ts
export const STATIC_METADATA_IMAGES = {
  icon: {
    filename: 'icon',
    extensions: ['ico', 'jpg', 'jpeg', 'png', 'svg'],
  },
  apple: {
    filename: 'apple-icon',
    extensions: ['jpg', 'jpeg', 'png'],
  },
  favicon: {
    filename: 'favicon',
    extensions: ['ico'],
  },
  openGraph: {
    filename: 'opengraph-image',
    extensions: ['jpg', 'jpeg', 'png', 'gif'],
  },
  twitter: {
    filename: 'twitter-image',
    extensions: ['jpg', 'jpeg', 'png', 'gif'],
  },
} as const

5 类静态图:

文件名后缀注入位置
favicon.icoico<link rel="icon">
icon.{ext}ico/jpg/jpeg/png/svg<link rel="icon">
apple-icon.{ext}jpg/jpeg/png<link rel="apple-touch-icon">
opengraph-image.{ext}jpg/jpeg/png/gif<meta property="og:image">
twitter-image.{ext}jpg/jpeg/png/gif<meta name="twitter:image">

2.1 fastPath 优化

91:packages/next/src/lib/metadata/is-metadata-route.ts
function fastPathCheck(normalizedPath: string): boolean | null {
  // Check favicon.ico first (most common)
  if (FAVICON_REGEX.test(normalizedPath)) return true

  // Check other common static files
  if (ROBOTS_TXT_REGEX.test(normalizedPath)) return true
  if (MANIFEST_JSON_REGEX.test(normalizedPath)) return true
  if (MANIFEST_WEBMANIFEST_REGEX.test(normalizedPath)) return true
  if (SITEMAP_XML_REGEX.test(normalizedPath)) return true

  // Quick negative check - if it doesn't contain any metadata keywords, skip
  if (
    !normalizedPath.includes('robots') &&
    !normalizedPath.includes('manifest') &&
    !normalizedPath.includes('sitemap') &&
    !normalizedPath.includes('icon') &&
    !normalizedPath.includes('apple-icon') &&
    !normalizedPath.includes('opengraph-image') &&
    !normalizedPath.includes('twitter-image') &&
    !normalizedPath.includes('favicon')
  ) {
    return false
  }
  // ...
}

注释告诉我们:framework 会先用 5 条快速正则筛选(favicon.ico 是最常见的,第一条);都不命中就走完整路径。这是典型的 hot path 优化——绝大多数 build 文件不是 metadata。

2.2 6 类动态元数据文件

除了静态图,还能用 TS/JS 文件动态生成

文件名用途
icon.tsx / icon.ts程序生成图标(如 <svg> 或 ImageResponse)
opengraph-image.tsx程序生成 OG 图
twitter-image.tsx程序生成 Twitter 图
robots.ts程序生成 robots.txt
sitemap.ts程序生成 sitemap.xml
manifest.ts程序生成 PWA manifest.json

每种文件遵循 default export 一个函数:

// app/sitemap.ts
import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const products = await getProducts();
  return [
    { url: "https://example.com/", lastModified: new Date() },
    ...products.map((p) => ({
      url: `https://example.com/products/${p.id}`,
      lastModified: p.updatedAt,
    })),
  ];
}

build 时 Next.js 会把它注册成 /sitemap.xml 的路由。

生产排查提示:sitemap 文件可以用 revalidate 控制更新频率,搭配 ② Data Cache,避免每次请求都查 DB。

3. 动态 OG image:next/og

next/og 提供 ImageResponse,让你用 JSX 写出动态 OG 图,由 SatoriEdge runtime 渲染为 PNG。

3.1 最简示例

// app/products/[id]/opengraph-image.tsx
import { ImageResponse } from "next/og";

export const runtime = "edge";
export const alt = "Product image";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function Image({ params }: { params: { id: string } }) {
  const product = await fetchProduct(params.id);

  return new ImageResponse(
    (
      <div
        style={{
          width: "100%",
          height: "100%",
          display: "flex",
          background: "linear-gradient(135deg, #1e3a8a, #7c3aed)",
          color: "white",
          padding: 64,
          fontSize: 64,
          alignItems: "center",
          justifyContent: "center",
        }}
      >
        {product.name}
      </div>
    ),
    { ...size },
  );
}

注意:

  • 必须 runtime = 'edge'(Satori 依赖 edge 环境的 streaming API)。
  • 必须 default export 函数返回 ImageResponse
  • size alt contentType 是 export 的命名常量——会被 build 工具识别。

3.2 Satori 的限制

ImageResponse 内部用 Satori 把 JSX 转 SVG,再用 @resvg/resvg-wasm 转 PNG。Satori 仅支持子集 CSS:

支持不支持
flex gap padding margingrid
linear-gradient radial-gradientconic-gradient
font-family(须显式提供)@font-face rule
border-radius box-shadowtransform: rotate3d
一部分 SVG<canvas> <video>

生产排查提示:渲染失败常因 CSS 不支持。dev 模式下访问 /products/123/opengraph-image 会直接跳错误页,提示具体哪条不支持。

3.3 自定义字体

Satori 不读浏览器字体——你必须显式 fetch 字体文件传入:

const interBuffer = await fetch(
  new URL('https://fonts.example.com/Inter.ttf', import.meta.url)
).then((r) => r.arrayBuffer())

return new ImageResponse(
  (...JSX...),
  {
    fonts: [
      {
        name: 'Inter',
        data: interBuffer,
        style: 'normal',
      },
    ],
  }
)

业务案例:

  • 中文字符——必须传中文字体(如 PingFang),否则显示成方块。
  • 多权重——每个权重单独传一份 buffer。
  • 动态字符——只在请求时拉子集字体(text=${text})减小体积。

3.4 Edge runtime 适配

OG 图常被搜索引擎/社交平台抓取,响应必须快runtime = 'edge' 让它在 edge 节点执行:

  • 冷启动 ~50ms(vs node ~500ms)。
  • 全球分发。
  • 但能用的 API 受限(无 fs、无原生 Buffer 完整 API)。

第 21 讲(middleware/edge)会详解 edge runtime 限制。

4. next/font 自托管字体

老 web 项目常 <link href="https://fonts.googleapis.com/..."> 拉 Google Fonts。问题:

  • 第三方 CDN 失败 → 字体闪屏(FOIT/FOUT)。
  • 隐私问题 → 用户 IP 暴露给 Google。
  • Layout shift → 字体 metrics 不一致。

next/font 解决这三件事:构建期下载到本地、注入 CSS、自动度量替换

4.1 Google Font 加载流程

60:packages/font/src/google/loader.ts
const nextFontGoogleFontLoader: FontLoader = async ({
  functionName,
  data,
  emitFontFile,
  isDev,
  isServer,
}) => {
  const {
    fontFamily,
    weights,
    styles,
    display,
    preload,
    selectedVariableAxes,
    fallback,
    adjustFontFallback,
    variable,
    subsets,
  } = validateGoogleFontFunctionCall(functionName, data[0])

  // Validate and get the font axes required to generated the URL
  const fontAxes = getFontAxes(
    fontFamily,
    weights,
    styles,
    selectedVariableAxes
  )

  // Generate the Google Fonts URL from the font family, axes and display value
  const url = getGoogleFontsUrl(fontFamily, fontAxes, display)

  // Get precalculated fallback font metrics, used to generate the fallback font CSS
  const adjustFontFallbackMetrics: AdjustFontFallback | undefined =

build 期做的事:

  1. 校验 weights styles subsets(与字体支持范围对照)。
  2. 算 axes(variable font 才有)。
  3. 拼出 Google Fonts CSS URL。
  4. 拉 CSS → 解出每个 woff2 URL → 拉 woff2 → emitFontFile() 写到 .next/static/media/
  5. 重写 CSS:把 Google URL 替换成本地 /_next/static/media/... 路径。
  6. 给 className 关联到 CSS。

4.2 自托管的好处

  • 零外部请求:所有字体跟着 build 一起部署。
  • HTTP/2 复用:用主域名连接拉字体,无需新连。
  • 度量替换:Next.js 内置数百种字体的 ascent descent lineGap 数据,自动注入 @font-facesize-adjust 等属性,消除 fallback → custom 字体切换时的抖动

4.3 使用方式

import { Inter, Noto_Sans_SC } from "next/font/google";

const inter = Inter({
  subsets: ["latin"],
  display: "swap",
});

const notoSansSC = Noto_Sans_SC({
  subsets: ["chinese-simplified"],
  weight: ["400", "700"],
  display: "swap",
});

export default function RootLayout({ children }) {
  return (
    <html lang="zh-CN" className={`${inter.variable} ${notoSansSC.variable}`}>
      <body className={notoSansSC.className}>{children}</body>
    </html>
  );
}

subsets 控制 Unicode 范围——只拉真正用到的,体积可减 70%。

4.4 本地字体

import localFont from "next/font/local";

const myFont = localFont({
  src: [
    { path: "./fonts/MyFont-Regular.woff2", weight: "400", style: "normal" },
    { path: "./fonts/MyFont-Bold.woff2", weight: "700", style: "normal" },
  ],
  display: "swap",
});

src 是相对组件文件的路径。Next.js 会复制并 hash 它们到 .next/static/media/

生产排查提示:字体加载慢?关键检查是 subsets(不要拉整个 Unicode)和 display: 'swap'(先用 fallback 再切换,避免文字消失)。

5. 静态资源 4 种放置

位置URL缓存策略用途
public/foo.png/foo.png直接走 CDN,build 不打包不变的资源、用户上传文件、/robots.txt
app/foo.png(约定文件名)/foo.png 或注入 metadatabuild 优化(hash + 缓存头)元数据图(favicon、og 等)
import logo from './logo.png'/_next/static/media/logo.[hash].png永久缓存(hash 失效)组件用图,自动 width/height
远程 CDNhttps://cdn.example.com/...第三方控制用户内容、动态产物

5.1 public/ vs import 选择

public/foo.png

  • ❌ 不能 tree-shake(即使没用也会被部署)。
  • ❌ 没有 hash,浏览器缓存可能命中旧文件。
  • ✅ 路径稳定,便于外链、SEO。

import logo from './logo.png'

  • ✅ 自动 hash,永久缓存。
  • ✅ 自动 import 时给出尺寸(<Image> 用得上,第 10 讲)。
  • ✅ 不用的图被 tree-shake。
  • ❌ 路径变化频繁,不稳定(不适合 SEO 资源)。

5.2 app/ 元数据文件的特殊性

你不能把任意图片放 app/——只有元数据约定的图(icon.png opengraph-image.tsx 等)才会被识别。其他 .png 文件会被忽略(不打包到 dist,但也不会让你 <img src="...">)。要按"约定"放或者改放 public/

5.3 业务案例:博客头像

  • 作者头像(用户上传)→ 远程 CDN。
  • 网站 logo(永远不变)→ import logo from './logo.svg' + <Image>
  • favicon → app/favicon.ico
  • 默认 OG 图(无文章时)→ app/opengraph-image.png
  • robots.txt → public/robots.txtapp/robots.ts(动态生成)。

6. 业务案例:电商商品页元数据

完整 SEO 配置:

// app/layout.tsx
import { Inter } from "next/font/google";
import type { Metadata } from "next";

const inter = Inter({ subsets: ["latin"], display: "swap" });

export const metadata: Metadata = {
  metadataBase: new URL("https://shop.example.com"),
  title: { default: "My Shop", template: "%s | My Shop" },
  description: "A modern e-commerce experience",
  openGraph: {
    type: "website",
    locale: "zh_CN",
    siteName: "My Shop",
  },
  twitter: { card: "summary_large_image", creator: "@myshop" },
  robots: { index: true, follow: true },
  verification: { google: "XXXX" },
};

export default function RootLayout({ children }) {
  return (
    <html lang="zh-CN" className={inter.className}>
      <body>{children}</body>
    </html>
  );
}
// app/products/[id]/page.tsx
import type { Metadata } from "next";

export async function generateMetadata({ params }): Promise<Metadata> {
  const { id } = await params;
  const product = await getProduct(id);

  return {
    title: product.name,
    description: product.summary,
    alternates: { canonical: `/products/${id}` },
    openGraph: {
      title: product.name,
      description: product.summary,
      images: [
        {
          url: `/products/${id}/opengraph-image`, // 来自下面的 .tsx
          width: 1200,
          height: 630,
        },
      ],
    },
  };
}
// app/products/[id]/opengraph-image.tsx
import { ImageResponse } from "next/og";

export const runtime = "edge";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";

export default async function Image({ params }) {
  const product = await getProduct(params.id);
  return new ImageResponse(
    (
      <div
        style={{
          display: "flex",
          alignItems: "center",
          justifyContent: "center",
          width: "100%",
          height: "100%",
          background: "#0f172a",
          color: "white",
          fontSize: 64,
        }}
      >
        {product.name}
      </div>
    ),
    { ...size },
  );
}
// app/sitemap.ts
import type { MetadataRoute } from "next";

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const products = await getAllProducts();
  return [
    { url: "https://shop.example.com", lastModified: new Date() },
    ...products.map((p) => ({
      url: `https://shop.example.com/products/${p.id}`,
      lastModified: p.updatedAt,
      changeFrequency: "weekly" as const,
    })),
  ];
}
// app/robots.ts
import type { MetadataRoute } from "next";

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [{ userAgent: "*", allow: "/", disallow: "/admin/" }],
    sitemap: "https://shop.example.com/sitemap.xml",
  };
}

这套配置的 SEO 检查清单:

  • ✅ 每页有独立 title + description。
  • ✅ canonical 防止重复内容。
  • ✅ OG image 自动生成(动态商品名)。
  • ✅ Twitter card 配 summary_large_image。
  • ✅ sitemap.xml 自动列所有产品。
  • ✅ robots.txt 屏蔽后台。
  • ✅ verification 接 Search Console。

7. 重难点

7.1 metadataBase 与相对 URL

openGraph.images: ['/og.png'] 会被 framework 自动拼成绝对 URL(https://shop.example.com/og.png),前提是设了 metadataBase没设的话生成的 meta tag 是相对 URL,社交平台抓取失败。所以 root layout 一定要设 metadataBase。

7.2 generateMetadata 与 page 共享数据

// 不优雅的写法:两次 fetch
export async function generateMetadata({ params }) {
  const product = await getProduct(params.id)  // 第一次
  return { title: product.name }
}

export default async function Page({ params }) {
  const product = await getProduct(params.id)  // 第二次(其实命中 ① Memoization)
  return <ProductDetail product={product} />
}

虽然 ① Request Memoization(第 13 讲)让两次 fetch 实际只发一次,但代码看着重复。可以用 cache(getProduct) 显式让 dedupe 更清晰。

7.3 OG image 的 fallback 链

社交抓取时 OG image 优先级:

  1. app/products/[id]/opengraph-image.{tsx,png,...}(最近 segment 的约定文件)。
  2. generateMetadata 返回的 openGraph.images
  3. 父 segment 的 opengraph-image
  4. 直至 root layout 的 app/opengraph-image

任何一层匹配则停止。调试时按这个顺序排查

7.4 next/font 与 CSS-in-JS 的冲突

next/font 注入的 className 与 CSS-in-JS 库(styled-components / emotion)的 className 是同一空间。如果你在 styled-component 里覆盖 font-family,会把 next/font 的样式覆盖掉,回退到系统字体。解决:在 layout 用 font.variable 加到 <html> 的 CSS variable,再在 styled-component 引用:

const inter = Inter({ variable: "--font-inter" });
// <html className={inter.variable}>

// 然后 styled-component 里 font-family: var(--font-inter), system-ui;

7.5 sitemap 大数据量

sitemap.ts 默认导出单个数组,超过 50000 条会触发 Google 限制。Next.js 支持分片:

export async function generateSitemaps() {
  return [{ id: 0 }, { id: 1 }]; // 生成 sitemap/0.xml sitemap/1.xml
}

export default async function sitemap({ id }: { id: number }) {
  const start = id * 50000;
  const products = await getProducts({ skip: start, take: 50000 });
  return products.map((p) => ({ url: `...${p.id}` }));
}

generateSitemaps 返回数组中每一项会调用一次 sitemap 函数。

8. 配套 fixture:完整元数据栈

fixtures/lecture-14/ 演示完整元数据 + 字体 + 静态资源:

  • app/layout.tsx — root metadata + Inter 字体
  • app/page.tsx — 首页
  • app/products/[id]/page.tsx — 商品页 + generateMetadata
  • app/products/[id]/opengraph-image.tsx — 动态 OG 图
  • app/sitemap.ts — 动态 sitemap
  • app/robots.ts — 动态 robots
  • app/manifest.ts — PWA manifest

8.1 推荐实验

cd learning/nextjs-40-lectures/fixtures/lecture-14
pnpm install --ignore-workspace
pnpm dev
# 浏览器打开 http://localhost:3014/

实验 A:查看 metadata

  • /products/p1 右键 → 查看源代码,看 <title> <meta> 等元素。
  • 注意 title 自动套上 root template %s | My Shop

实验 B:访问动态 OG 图

  • 直接打开 http://localhost:3014/products/p1/opengraph-image
  • 看到一张 PNG 图,内容是商品名。
  • 修改商品名,刷新看图变化。

实验 C:sitemap 与 robots

  • 访问 /sitemap.xml 看 XML 内容。
  • 访问 /robots.txt 看 robots 配置。

实验 D:字体子集

  • 打开 Network 面板,过滤 media —— 看到 woff2 字体本地加载。
  • subsets'latin-ext' 重启,文件大小变化明显。

9. 检验问题

  1. metadata 静态导出与 generateMetadata 的差异?哪些可以共存?
  2. title 三种形态(string / TemplateString.default / absolute)各自语义?
  3. 在 child segment 修改 metadata,与父级是覆盖关系还是合并?哪些字段例外?
  4. STATIC_METADATA_IMAGES 的 5 类约定文件分别注入到 HTML 哪个 tag?
  5. app/products/[id]/opengraph-image.tsx 的 default export 函数返回什么?为什么必须 edge runtime?
  6. Satori 不支持哪些 CSS?没字体时中文显示什么?
  7. next/font/google 在 build 期做了什么?为什么能消除 layout shift?
  8. public/foo.pngimport foo from './foo.png' 的关键差别(4 条以上)?
  9. metadataBase 没设会怎样?
  10. fixture 实验 D 中,subsets 改后 woff2 大小变化的原因?

10. 延伸阅读

  • 源码:packages/next/src/lib/metadata/is-metadata-route.ts(约定路由识别)
  • 源码:packages/next/src/lib/metadata/resolve-metadata.ts(合并逻辑)
  • 源码:packages/next/src/lib/metadata/types/metadata-interface.ts(类型定义)
  • 源码:packages/next/src/server/og/image-response.ts(ImageResponse 包装)
  • 源码:packages/font/src/google/loader.ts(Google Font 加载流程)
  • 文档:docs/01-app/03-api-reference/02-file-conventions/metadata-files/*.mdx
  • 配套 fixture:fixtures/lecture-14/

阶段二收官

恭喜——你已经走完 App Router 的核心面:

  • 第 5 讲:文件系统约定与 segment 树
  • 第 6 讲:Manifest 体系
  • 第 7 讲:Server / Client Components 边界
  • 第 8 讲:LoaderTree 装配
  • 第 9 讲:客户端 router + segment cache
  • 第 10 讲:Link / Form / Image / Script 内置组件
  • 第 11 讲:fetch / unstable_cache / 'use cache'
  • 第 12 讲:Server Actions
  • 第 13 讲:4 层缓存全景
  • 第 14 讲:metadata + OG + 字体 + 静态资源

阶段三我们将进入"渲染管线"——从一个 HTTP 请求进入服务器,到流式 RSC payload 写出,再到客户端 hydrate 完成的完整旅程。先看 第 15 讲|请求一生:从 router-server 到 app-render