一个页面对人好看,并不代表搜索引擎、分享平台和自动化程序也能读懂它。浏览器标签页需要标题,搜索结果需要摘要,分享卡片需要图片,爬虫需要知道哪些地址可以访问,书籍详情还需要一份机器可读的结构化描述。
早期网站常在每个 HTML 文件里手写 <head>。组件化之后,人们又把标签分散到页面组件或第三方库中。App Router 的 Metadata API 把这些信息纳入路由树:根布局提供默认值,普通页面覆盖固定字段,动态页面根据数据生成标题和描述,特殊文件生成 robots.txt 与 sitemap.xml。
本课会完成拾光书架的可发现性,同时加入两类检查。Vitest 守住纯业务规则,真实页面截图和浏览器检查守住渲染结果。它们检查的对象不同,不能互相替代。
Next.js 16 提供两种主要写法:
export const metadata:适合编译时就知道的固定信息。export async function generateMetadata:适合需要读取 params 或数据的动态信息。二者只能在 Server Component 中导出,不能放进带 "use client" 的组件。根布局的 metadata 会向下生效,页面可以覆盖它。标题模板尤其适合站点:根布局定义 %s|拾光书架,子页面只需返回“书架”或某本书名。
元数据不是关键词堆砌。标题要准确描述当前页面,description 要能独立说明内容。每个页面机械复制同一段文字,对读者和搜索引擎都没有帮助。
先列出路由与元数据来源:
检查前两课的页面文件,确认固定页面使用类型标注:
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "书架",
description: "按关键词和阅读状态查找拾光书架中的书。",
};TypeScript 会检查 Metadata 字段拼写和结构,减少手写 <meta> 时常见的属性错误。
打开 /books 后,浏览器标签页显示“书架|拾光书架”,页面 description 是专门描述筛选书架的句子。打开 /notes 时,标题与摘要随页面改变。
这说明根布局提供了站点身份,叶子页面补充了当前内容。页面不需要手写 <head>,也没有重复拼接站名。
Open Graph 图片、canonical 和其他分享字段最终需要绝对 URL。metadataBase 为相对路径提供站点源地址。例如,当 base 是 https://books.example.com 时,/share.png 可以被解析为完整图片网址。
站点地址会随部署目标变化,因此从 NEXT_PUBLIC_SITE_URL 读取。开发时可以保留回退值,正式发布必须显式设置真实 HTTPS 域名,否则 sitemap、robots 和分享链接可能指向错误地址。
title.template 只应用于子路由的标题;根布局自己的默认标题仍是 title.default。Open Graph 则给社交平台一组明确的标题、描述和图片。
先创建 src/lib/site.ts,集中处理站点 URL:
export function getSiteUrl() {
const fallback = new URL("http://localhost:3000");
const value = process.env.NEXT_PUBLIC_SITE_URL;
if (!value) return fallback;
try {
return new URL(value);
} catch {
return fallback;
}
}这个 helper 的输入是可选环境变量,输出始终是可用的 URL 对象。new URL 会拒绝缺少协议或格式错误的值,因此用 try/catch 把错误配置收敛到明确回退地址。集中处理还能避免 layout、robots 和 sitemap 各自用字符串拼接,产生双斜线或不同回退规则。
然后在 src/app/layout.tsx 的既有 import 区新增这一行。Metadata、字体、页头和页脚导入都已经存在,不要重复添加:
import { getSiteUrl } from "@/lib/site";接着用下面的完整对象替换原有 export const metadata,文件中的字体配置与 RootLayout 保持不动:
export const metadata: Metadata = {
metadataBase: getSiteUrl(),
title: {
default: "拾光书架",
template: "%s|拾光书架",
},
description:
"整理想读、在读和读完的书,也把真正留下来的句子分享给同路的人。",
openGraph: {
type: "website",
locale: "zh_CN",
siteName: "拾光书架",
images: [
"https://media.edu-free.com/uploads/next_course_2026_cover_06a5e6993b.png",
],
},
metadataBase 接收 URL,为后代元数据中的相对地址提供基准。title.default 只用于根页面,title.template 把子页面的“书架”“共读墙”或书名组合成完整标题。Open Graph 的 type、locale 与 siteName 描述站点身份,图片数组则提供分享预览资源。
常见错误是直接写 new URL(process.env.NEXT_PUBLIC_SITE_URL!):变量缺失或为空时,构建会立即抛错。另一个错误是把密钥放进 NEXT_PUBLIC_ 变量;该前缀表示值可能公开进入浏览器产物,站点地址适合,密码不适合。
在项目根目录创建 .env.local,为这一阶段的运行与构建写入站点地址:
NEXT_PUBLIC_SITE_URL=https://books.example.com.env.local 默认不会提交到 Git。修改站点地址后要重新启动开发进程;生产构建也必须在执行 npm run build 之前提供最终 HTTPS 域名。NEXT_PUBLIC_ 值会被 Next.js 固定进构建产物,不能指望构建完成后再改运行进程的环境变量来重写已生成的 sitemap。回退地址只保证学习过程可运行,不应成为正式 sitemap 的域名;第 10 课会把它作为 Docker 构建参数传入。
根页面会生成对应的标题、description 和 Open Graph 标签。分享平台读取时能拿到:
og:type website
og:site_name 拾光书架
og:image https://media.edu-free.com/uploads/next_course_2026_cover_06a5e6993b.png图片地址已经是绝对 HTTPS URL。部署域名改变时,只需调整站点地址配置,不需要在每个页面搜索替换域名。
书籍详情页的标题必须来自书籍数据。generateMetadata 与页面组件接收同一份异步 params;Next.js 16 中要先 await params,再读取 slug。
元数据函数也要处理不存在的记录。如果页面对未知 slug 进入 notFound(),而 metadata 却生成一个看似正常的标题,机器和读者会得到矛盾信号。这里复用 getBook,找不到时同样调用 notFound()。
getBook 已经通过 "use cache" 管理读取。同一条数据既服务 metadata,也服务页面主体,不需要为“SEO”再建一套数据源。
打开 src/app/books/[slug]/page.tsx,核对第 5 课已经写入的以下声明。它们应当各出现一次;这一步只检查,不要再复制一份 import、BookPageProps 或 generateMetadata:
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getBook } from "@/lib/data";
type BookPageProps = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata({
params,
}: BookPageProps)
页面组件继续保留第 5 课已经完成的全文。它执行同样的三步:await params 得到 slug,await getBook(slug) 取得书籍,空结果调用 notFound();存在时再返回那份完整详情 JSX。不要为了“省一次调用”把 metadata 与页面正文塞进同一个导出,框架需要分别调用它们,而缓存的 getBook 会复用数据结果。
notFound() 的返回类型是 never,调用后当前分支不会继续,因此 TypeScript 能在后续代码中把 book 收窄为 Book。常见错误是只在页面正文处理缺失记录,却让 metadata 为未知 slug 生成正常标题,造成机器信息与界面矛盾。
访问 /books/the-long-river 时,最终标题是:
长河入夜|拾光书架description 使用这本书自己的摘要。访问不存在的 /books/not-a-book 时,元数据和页面共同进入 404 流程,不会生成一个虚假的详情页。
普通 meta 标签描述“页面”,JSON-LD 可以描述页面中的实体。书籍详情适合 Schema.org 的 Book 类型,至少提供名称、作者和简介。搜索引擎与其他机器可以据此理解“这是一本书”,而不只是猜测页面上的文字。
Next.js 推荐把 JSON-LD 渲染成 <script type="application/ld+json">。这里必须注意注入风险:JSON.stringify 会生成 JSON,却不会自动阻止字符串中的 < 结束 script 上下文。把 < 替换为 Unicode 转义 \u003c,可以避免数据被解释为 HTML 标签开头。
结构化数据只应描述页面真实展示的内容。不要捏造评分数量、价格或作者信息来争取富结果。
在书籍存在性检查之后创建对象,并把 script 放在详情内容前:
const jsonLd = {
"@context": "https://schema.org",
"@type": "Book",
name: book.title,
author: {
"@type": "Person",
name: book.author,
},
genre: book.category,
description: book.summary,
};
const safeJsonLd = JSON.stringify(jsonLd).replace(/</g, "\\然后把下面这个完整元素放在现有 <article> 的第一个子节点,后面的返回链接与详情网格保持不变:
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: safeJsonLd }}
/>对象的输入全部来自当前页面真实展示的 book,输出是一个 JSON 字符串。genre 对应可见分类;author 使用嵌套 Person,不能误写成与 name 平级的任意字段。dangerouslySetInnerHTML 这个名字是在提醒我们:内容不会经过 React 的普通文本转义。这里传入的是经过字符串化和 < 转义的受控对象,不要直接塞入用户提交的 HTML。
《长河入夜》的页面包含一段可解析 JSON:
{
"@context": "https://schema.org",
"@type": "Book",
"name": "长河入夜",
"author": {
"@type": "Person",
"name": "林见川"
},
"genre": "小说",
"description": "一名修船匠沿河寻找失散多年的旧友,也重新认识自己生活的城市。"
}若未来书名中含有 <,HTML 源码里会出现 \u003c,解析 JSON 后仍还原为原字符。我们同时保留了数据含义和 script 边界。
robots.txt 告诉遵守 Robots Exclusion Protocol 的爬虫哪些路径允许抓取,并可以指出 sitemap 地址。它不是访问控制:禁止抓取不会让私人页面变安全,真正的私人数据仍要认证和授权。
sitemap.xml 是站点希望机器发现的 URL 清单。静态页面可以手写,书籍详情页则应该从 getBooks() 生成,避免新增书后忘记更新清单。
App Router 为两者提供特殊文件约定:
src/app/robots.ts 输出 /robots.txt。src/app/sitemap.ts 输出 /sitemap.xml。使用 MetadataRoute 类型后,Next.js 会负责正确的响应格式。
创建 src/app/robots.ts:
import type { MetadataRoute } from "next";
import { getSiteUrl } from "@/lib/site";
export default function robots(): MetadataRoute.Robots {
const siteUrl = getSiteUrl();
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: new URL(
再创建 src/app/sitemap.ts:
import type { MetadataRoute } from "next";
import { getBooks } from "@/lib/data";
import { getSiteUrl } from "@/lib/site";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const siteUrl = getSiteUrl();
const books = await getBooks();
const fixedRoutes
MetadataRoute.Robots 与 MetadataRoute.Sitemap 检查返回对象形状,Next.js 再把对象序列化成对应响应。new URL(path, siteUrl) 负责斜线归一化;不要改回 ${baseUrl}/${path},否则 base 末尾已有斜线时容易产生重复分隔符。
固定路由直接映射为四个对象,书籍路由则由缓存书单生成六个对象,最终输出十条 URL。课程数据没有可信更新时间,所以不伪造 lastModified: new Date();那会在每次生成时宣称所有内容刚刚修改。以后数据模型真正拥有更新时间,再把它映射进 sitemap。
访问 /robots.txt 会看到允许抓取全站的规则和 sitemap 地址。/sitemap.xml 包含四个固定页面和六个书籍详情,共十条 URL。
检查任意详情地址,例如 /books/morning-star-map,它应该同时满足:
noindex;当前流式详情路由的 HTTP 状态可能保持 200,监控时要同时检查页面语义。字体和图片直接影响首屏速度与布局稳定性。next/font 会在构建时处理字体文件,并通过生成的 class 或 CSS 变量应用,不需要浏览器再向字体服务发起请求。字体加载与布局连接后,也能减少文字突然换字形造成的位移。
next/image 需要知道图片尺寸。静态导入可以自动获得宽高;远程图片要显式提供 width、height,并通过 images.remotePatterns 限定允许的协议、主机和路径。白名单越具体,越不容易让任意第三方地址借用图片优化入口。
sizes 描述图片在不同视口实际占多宽,帮助浏览器选择合适候选图。alt 则描述图片传达的信息;纯装饰图才使用空 alt。
根布局已经在模块顶层配置 Geist 字体。为了避免只复制字体片段时误删页头、主内容区或页脚,下面给出这一阶段完整的 src/app/layout.tsx;用它核对整个文件:
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { SiteFooter } from "@/components/site-footer";
import { SiteHeader } from "@/components/site-header";
import { getSiteUrl } from "@/lib/site";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"
Geist(...) 的输入是字体选项,输出包含可挂到元素上的 CSS 变量名。subsets: ["latin"] 只打包拉丁字符,不包含中文;中文正文仍使用系统回退字体。variable 也不会自动改变所有文字,项目的全局 CSS 需要把 --font-geist-sans、--font-geist-mono 接到实际字体规则上。
把变量挂在 <html> 可供整棵页面使用,lang="zh-CN" 则告诉浏览器和辅助技术主要语言。常见错误是看到 .woff2 就以为中文也来自 Geist,或只创建变量却从未在 CSS 中引用它。
为课程封面添加精确远程规则,更新 next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
cacheComponents: true,
output: "standalone",
images: {
remotePatterns: [
{
protocol: "https",
hostname: "media.edu-free.com",
pathname: "/uploads/**",
},
],
},
};
export default nextConfig;在 src/app/about/page.tsx 使用远程图片。用下面的完整文件替换页面:
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
export const metadata: Metadata = {
title: "关于",
description: "了解拾光书架如何从一个页面成长为完整项目。",
};
export default function AboutPage() {
return (
<section className
width 与 height 输入源图片的固有比例,让浏览器在下载前预留空间;真正的响应式显示由 w-full h-auto 和外层网格控制。sizes 告诉浏览器:窄屏接近整个视口,桌面约占 58vw,以便从 srcset 选择合理文件。priority 适合这个首屏核心图,不要给长列表里的每一张图片都加,否则会争抢带宽。
remotePatterns 是图片优化入口的服务器白名单,协议、主机和路径都必须匹配;修改配置后需要重启进程。它不控制 metadata 中的 Open Graph URL,二者用途不同。
关于页图片保留约 1672:941 的宽高比,加载前浏览器就能预留空间。窄屏使用接近视口宽度的候选图,宽屏按约 768 像素展示,不会无条件下载最大尺寸。
构建产物中还能看到由 next/font 生成的 .woff2 文件。页面文字使用 CSS 变量,字体与组件结构没有耦合。
下面是运行后的关于页。URL 为 http://localhost:3000/about,视口为 1440×900;等待字体与封面完成加载后捕获。观察左右双栏、图片固定比例、边框阴影和“从书架继续”按钮,页面没有因远程图片载入发生明显跳动。

截图左下角的黑色 N 来自 next dev 的开发指示器,不是页脚或浮动按钮。生产构建不会交付它;页面真正的组件从顶部导航开始,到底部两段页脚文字结束。
Open Graph 图片只是 metadata 中的公开 URL,不经过 next/image 组件。remotePatterns 控制的是页面使用 Image 优化远程图片的范围,两者用途不同。
自动化测试先选稳定、边界清楚的对象。noteSchema 是纯函数式规则:给定输入,结果确定,没有浏览器、网络或文件写入,适合单元测试。
异步 Server Component 涉及 Next.js 渲染上下文,Vitest 不适合直接替代完整浏览器流程。页面导航、流式渲染和 Server Action 成功链更适合端到端检查。本课先用 Vitest 守住 schema,再用下一节的真实页面检查覆盖渲染。
测试文件使用 @/ 路径别名,Vitest 不会自动读取 Next.js 的全部解析配置,所以要在配置中显式映射到 src。这组测试不渲染 DOM,environment 使用 node 即可。
安装 Vitest,并在 package.json 的现有 scripts 对象中增加一次性运行脚本:
npm install --save-dev --save-exact vitest@4.1.10{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"typecheck": "tsc --noEmit",
"test": "vitest run"
}
}这里展示的是合并后的完整 scripts,不能用只含 test 的对象覆盖原文件,否则启动、构建、检查命令都会消失。--save-exact 会让 package.json 与锁文件都固定在本课实际验证的 Vitest 4.1.10。
创建 vitest.config.ts:
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
},
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
});配置文件使用 ESM。import.meta.url 是当前配置文件的 file URL,new URL("./src", ...) 在它旁边定位 src,fileURLToPath 再转换为操作系统路径。输出的 alias 让测试中的 @/lib/validation 与 Next.js 源码使用同一导入方式。这里不渲染 DOM,所以 environment: "node" 足够。
创建 src/lib/validation.test.ts:
import { describe, expect, it } from "vitest";
import { noteSchema } from "@/lib/validation";
describe("noteSchema", () => {
it("会清理合法输入两端的空格", () => {
const result = noteSchema.parse({
bookSlug: " the-long-river ",
reader: " 小虎 ",
reflection: " 这段文字已经超过十个字,可以被收录。 ",
});
expect
第一条使用会在失败时抛错的 parse,直接检查 trim 后的完整输出;第二条使用 safeParse,先收窄失败分支,再断言错误确实属于 reflection。只检查 success === false 过于宽泛:如果将来样例因错误 slug 失败,那条测试也会误通过。
执行 npm run test,实际结果是一个测试文件、两条测试全部通过:
RUN v4.1.10
✓ src/lib/validation.test.ts (2 tests)
Test Files 1 passed (1)
Tests 2 passed (2)如果以后把最短读后感改为 20 个字,第二条仍会通过,第一条测试数据则可能需要随新规则调整。测试失败会指出规则和示例已经不一致,而不是让变化悄悄进入发布版本。
单元测试能证明 noteSchema 对两个输入的判断,却看不到字体是否加载、卡片是否溢出、Open Graph 标签是否生成,也看不到 Server Action 成功后列表是否刷新。
真实截图必须来自正在运行的项目页面,而不是设计稿或手工拼出的示意图。截图负责留下视觉证据;浏览器中的 DOM 与网络检查负责确认肉眼看不到的信息。两者结合,才能说明“我们检查的是实际产物”。
截图前先确定状态、视口和等待条件。搜索页应固定关键词和状态;共读墙应明确是错误态还是成功态;异步内容与字体加载完成后再捕获,避免把骨架屏当成最终结果。
如果开发服务器仍在运行,继续使用现有进程;只有已经停止时才执行:
npm run dev使用终端给出的地址完成下面的检查:
在 1440×900 视口打开 /books?q=城市&status=finished,等卡片和字体稳定后截图。画面应包含 URL 对应的输入值、结果数量和《雨后博物馆》。
在 390×844 视口检查同一页面。表单应改为单列,按钮和卡片不应产生水平滚动。
打开一本书的详情页,在开发者工具 Elements 中确认存在 script[type="application/ld+json"],并复制内容做 JSON 解析。
分别打开 /robots.txt 与 ,确认响应不是普通 HTML,且 sitemap 含六本书的详情地址。
还可以在详情页控制台执行三条只读检查:
document.title;
document
.querySelector('meta[name="description"]')
?.getAttribute("content");
JSON.parse(
document.querySelector(
'script[type="application/ld+json"]',
)?.textContent ?? "{}",
);最终证据应覆盖三层:
为什么已经有真实截图,还要运行 Vitest?
现在,页面既能被人使用,也能被机器理解,并且有了第一组可重复执行的检查。最后一课会把这些检查组织成发布门禁,读懂 Next.js 16.2.10 的构建路由符号,再把应用作为独立 Node.js 服务或容器运行。
/sitemap.xml