- 发布日期
第 14 讲|元数据、OG image、字体、静态资源
Metadata API、OG image 生成、next/font 零布局偏移与静态资源处理
阶段二:App Router 核心机制 · 第 14 / 40 讲(阶段二收官) 难度:⭐⭐⭐ · 预计耗时:3 小时 配套 fixture:
fixtures/lecture-14/
学习目标
- 看懂
metadata静态导出 +generateMetadata动态生成的执行模型与合并规则。 - 掌握"约定路由"对元数据的特殊处理:
favicon.icoicon.pngopengraph-image.tsxrobots.txtsitemap.xml等。 - 用
next/og的ImageResponse动态生成 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],
},
};
}
parent 是 Promise<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 也走 ③ 缓存。需要
revalidatePath或revalidateTag。
2. 约定路由:metadata 文件
App Router 把"放对位置的文件"自动当成元数据路由处理。看源码定义:
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.ico | ico | <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 优化
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。 sizealtcontentType是 export 的命名常量——会被 build 工具识别。
3.2 Satori 的限制
ImageResponse 内部用 Satori 把 JSX 转 SVG,再用 @resvg/resvg-wasm 转 PNG。Satori 仅支持子集 CSS:
| 支持 | 不支持 |
|---|---|
flex gap padding margin | grid |
linear-gradient radial-gradient | conic-gradient |
font-family(须显式提供) | @font-face rule |
border-radius box-shadow | transform: 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 加载流程
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 期做的事:
- 校验
weightsstylessubsets(与字体支持范围对照)。 - 算 axes(variable font 才有)。
- 拼出 Google Fonts CSS URL。
- 拉 CSS → 解出每个 woff2 URL → 拉 woff2 →
emitFontFile()写到.next/static/media/。 - 重写 CSS:把 Google URL 替换成本地
/_next/static/media/...路径。 - 给 className 关联到 CSS。
4.2 自托管的好处
- 零外部请求:所有字体跟着 build 一起部署。
- HTTP/2 复用:用主域名连接拉字体,无需新连。
- 度量替换:Next.js 内置数百种字体的
ascentdescentlineGap数据,自动注入@font-face的size-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 或注入 metadata | build 优化(hash + 缓存头) | 元数据图(favicon、og 等) |
import logo from './logo.png' | /_next/static/media/logo.[hash].png | 永久缓存(hash 失效) | 组件用图,自动 width/height |
| 远程 CDN | https://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.txt或app/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 优先级:
app/products/[id]/opengraph-image.{tsx,png,...}(最近 segment 的约定文件)。generateMetadata返回的openGraph.images。- 父 segment 的
opengraph-image。 - 直至 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— 商品页 + generateMetadataapp/products/[id]/opengraph-image.tsx— 动态 OG 图app/sitemap.ts— 动态 sitemapapp/robots.ts— 动态 robotsapp/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. 检验问题
- metadata 静态导出与 generateMetadata 的差异?哪些可以共存?
- title 三种形态(string / TemplateString.default / absolute)各自语义?
- 在 child segment 修改 metadata,与父级是覆盖关系还是合并?哪些字段例外?
- STATIC_METADATA_IMAGES 的 5 类约定文件分别注入到 HTML 哪个 tag?
app/products/[id]/opengraph-image.tsx的 default export 函数返回什么?为什么必须 edge runtime?- Satori 不支持哪些 CSS?没字体时中文显示什么?
- next/font/google 在 build 期做了什么?为什么能消除 layout shift?
public/foo.png与import foo from './foo.png'的关键差别(4 条以上)?- metadataBase 没设会怎样?
- 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。