上一节中,浏览器访问 /,Next.js 找到了 src/app/page.tsx。现在我们把这条规则扩展开,让“拾光书架”拥有关于页、书架入口和图书占位详情。
这一阶段只处理路由,不提前加入 JSON 数据、图书卡片或收藏状态。先让每个地址稳定地找到页面,后面再把真实数据接进来,错误会更容易定位。

src/app 是 App Router 的根目录。目录每向下一级,就多一个网址片段;只有目录中出现 page.tsx,这个位置才会成为可以直接访问的页面。
例如,src/app/about/page.tsx 对应 /about,src/app/books/page.tsx 对应 /books。文件夹 [slug] 用方括号声明动态片段,所以 src/app/books/[slug]/page.tsx 可以接住 /books/the-long-river 和 /books/slow-observation。
slug 是放进网址的稳定文本标识。它不是固定目录名,而是这一次请求带来的值。等数据层加入后,页面会用 slug 找到对应的书。
用编辑器在 src/app 下建立 about、books 和 books/[slug]。同时准备 books/loading.tsx 与根级 not-found.tsx。相关结构应是:
src/app/
├── about/
│ └── page.tsx
├── books/
│ ├── [slug]/
│ │ └── page.tsx
│ ├── loading.tsx
│ └── page.tsx
├── layout.tsx
├── not-found.tsx
└── page.tsx先不写页面内容,逐行说出下面的对应关系:
最后一行不会寻找名为 the-long-river 的真实目录。[slug] 是参数槽,the-long-river 是本次填进槽里的值。
page.tsx 是某个地址自己的内容,layout.tsx 是这个目录及其后代页面共享的外壳。根布局必须输出 html 和 body,其中的 children 会替换成当前 Page。
页面切换时,Next.js 可以保留共享布局,只更新变化的部分。页头和页脚因此适合放进根布局。不过这一篇先保留最小外壳;下一篇再建立正式的 Header、Footer、字体和全局颜色。
把 src/app/layout.tsx 改成下面的阶段版本:
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: { default: "拾光书架", template: "%s|拾光书架" },
description: "整理想读、在读和读完的书。",
};
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
这段布局有三个输入来源。globals.css 提供所有页面共享的样式;静态导出的 metadata 提供文档标题和描述;组件参数中的 children 是当前地址匹配到的 Page。一次 /about 请求到来时,Next.js 先取得 About Page 的结果,再把它放进 <body>{children}</body>,最终组成完整文档。
import type 表示 Metadata 只参与 TypeScript 检查,编译后的运行代码不会保留它。标题对象中的 default 用于子页面没有单独标题时,template 则会把子页面标题放进 %s;例如关于页后来导出 title: "关于",最终标题就是“关于|拾光书架”。这比每个页面手动拼接站点名称更不容易漏改。
Readonly<{ children: React.ReactNode }> 描述组件参数:children 可以是元素、文字、列表或空值,Readonly 防止函数内部把这个参数重新赋值。它们都是类型约束,不会在浏览器中额外创建节点。根布局必须默认导出,也必须保留 html 和 body;删除 {children}、在每个 Page 再写一套 html,或为了标题手写第二个 head,都会破坏框架负责的文档组合。
lang="zh-CN" 声明页面主要语言。children 外面暂时没有导航,这让我们能先单独验收每个路由。
保存后刷新 /。初始首页应继续显示,浏览器标签标题变成“拾光书架”。如果删除 {children},Page 会失去显示位置;恢复后页面重新出现,这正是布局与页面的组合关系。
Layout 适合放真正共享的结构,不适合读取会随当前页面变化的搜索参数。Next.js 会在导航间复用布局;变化频繁的数据应该交给 Page 或其中的客户端组件。
站内导航使用 next/link 提供的 Link。它最终生成语义正确的链接,Next.js 可以预取相关路由,并在导航时复用共享布局。站外地址、文件下载和特殊协议仍可以使用普通的 a。
我们先做两个没有数据依赖的页面。关于页说明项目范围;书架页只放两条占位链接,证明 /books 可以带用户进入动态路由。真实列表会在数据章节替换这些占位内容。
创建 src/app/about/page.tsx:
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "关于",
description: "拾光书架的项目边界与学习目标。",
};
export default function AboutPage() {
return (
<main className="mx-auto max-w-3xl px-5 py-16">
<p
这个文件承担两项职责:为 /about 提供页面信息,也提供真正可见的项目说明。地址来自文件位置 app/about/page.tsx,不是来自函数名 AboutPage;函数名可以帮助阅读和调试,但 page.tsx 的默认导出才是 App Router 寻找的入口。
运行时,Next.js 读取本页 metadata,把标题交给根布局模板,再执行 AboutPage() 得到 JSX。main 表示这一阶段页面的主要内容,h1 描述页面主题,Link 最终生成可以被键盘和搜索引擎理解的链接。href="/" 以斜杠开头,明确指向站点根地址。
类名只负责外观,不改变路由行为:max-w-3xl 限制正文宽度,mx-auto 居中,px-5 py-16 提供内外留白。常见错误是把文件写成 app/about.tsx、忘记默认导出,或用带 onClick 的 div 模拟返回链接;这些写法分别会导致路由不存在或键盘导航不完整。
创建 src/app/books/page.tsx:
import type { Metadata } from "next";
import Link from "next/link";
export const metadata: Metadata = {
title: "书架",
description: "拾光书架的图书入口。",
};
const previewLinks = [
{ slug: "the-long-river", title: "长河入夜" },
{ slug: "slow-observation", title: "缓慢观察手册" },
]
previewLinks 放在组件外,是因为它不会随一次渲染而改变;每次执行 Page 都可以直接复用这份只读数据。as const 把数组中的字符串收窄为具体字面量,同时防止后面的代码误改数组内容。它不是运行时数据库,这一阶段只负责提供两个可观察的路由入口。
map() 的输入是一条 { slug, title },输出是一条 li。模板字符串 `/books/${book.slug}` 把每本书的 slug 放进地址;key={book.slug} 让 React 能稳定识别列表项。点击链接后,浏览器把新地址交给 App Router,根布局可以复用,动态 Page 则接收新的 slug。
不要把数组下标写成 key,也不要把 href 写成没有开头斜杠的 books/...:前者在插入或排序后可能把旧节点对应错,后者会以当前地址为基准拼出意外路径。这里使用 Link 而不是按钮,是因为用户的动作是“前往另一个地址”,不是“修改当前页面状态”。
打开 http://localhost:3000/about,应看到项目说明和“返回首页”。打开 http://localhost:3000/books,应看到两本书的链接。点击链接后,地址会进入 /books/...;下一节会让动态 Page 接住它。
在 Next.js 16 中,Page 收到的 params 是 Promise。服务器 Page 必须先 await params,再读取 slug。旧教程常见的 params: { slug: string } 同步写法不适合这套版本。
这一阶段不查询书籍数据,只维护两个允许访问的占位 slug。这样可以先观察路由参数、预生成参数与 404 分支,后续再把静态集合替换成真正的数据函数。
generateStaticParams() 返回构建时已知的动态参数。它不会替代运行时检查:用户仍可能手动输入其他地址,所以页面还要调用 notFound()。
创建 src/app/books/[slug]/page.tsx:
import Link from "next/link";
import { notFound } from "next/navigation";
const previewBooks = {
"the-long-river": "长河入夜",
"slow-observation": "缓慢观察手册",
} as const;
type PreviewSlug = keyof typeof previewBooks;
type BookPageProps = { params: Promise<{ slug
先看类型如何跟运行时检查配合。keyof typeof previewBooks 会得到 "the-long-river" | "slow-observation",因此 PreviewSlug 只允许这两个键;但来自网址的 slug 仍然只是普通 string。Object.hasOwn(previewBooks, slug) 在运行时确认这个字符串确实是对象自己的属性,避免把 constructor 等原型名称误当作书籍。
TypeScript 不会因为 Object.hasOwn() 自动把任意字符串收窄为对象键,所以通过检查后才使用 slug as PreviewSlug。断言不能替代检查;如果直接断言并索引,未知地址会得到 undefined,页面可能继续输出一张标题为空的卡片。
generateStaticParams() 把对象的两个键转换成 { slug } 数组,供生产构建提前知道常见详情地址。它的输出是构建输入,不是运行时白名单。一次真实请求仍按“匹配 [slug] → 等待 params → 检查键 → 取标题 → 返回 JSX”的顺序执行,检查失败则由 notFound() 立刻终止当前路由段。
JSX 中的 {slug} 仍是小写字符串 the-long-river。uppercase 只在显示层把拉丁字母转成大写,所以截图会看到 THE-LONG-RIVER;路由参数本身没有被修改,传给数据函数时也仍应使用原值。tracking-[0.25em] 增加字距,方括号表示 Tailwind 的任意值语法。
打开 http://localhost:3000/books/the-long-river,页面应显示“动态路由参数:THE-LONG-RIVER”和《长河入夜》。再访问 /books/slow-observation,同一份 page.tsx 会显示另一本书。屏幕上的大写来自 CSS,地址栏仍保持小写 slug。
下面是 http://localhost:3000/books/the-long-river 在这一阶段的真实页面。截图使用 1440×900 视口,从书架占位页点击《长河入夜》后取得;观察地址对应的 slug、页面标题,以及“返回书架”链接是否同时出现。此时还没有接入 JSON 数据,所以说明文字只是固定的“一本关于时间、记忆与回望的书。”,下一课才会换成真实书籍摘要。

请求过程可以这样复述:
App Router 用 /books/the-long-river 匹配 books/[slug]/page.tsx,并把动态片段包装成参数 Promise 交给 Page。
Page 等待 params,取得小写的 the-long-river,再检查它是否位于当前允许的占位集合中。
Object.hasOwn() 只接受占位对象自己的键,不会把 toString、constructor 等原型链名称误认成图书。检查通过后,Page 输出参数标签、书名、说明和返回链接;检查失败时,notFound() 会终止当前路由段并交给未找到页面。
loading.tsx 是路由段约定。页面内容发生异步暂停时,它会提供即时 fallback,并自动形成 Suspense 边界。站内导航不必等整个新页面完成后才给出反馈。
notFound() 处理“地址形状正确,但资源不存在”的预期分支。它会停止当前路由段的渲染,显示最近的 not-found.tsx,并向页面加入 noindex,避免搜索引擎收录不存在的资源。
创建 src/app/books/loading.tsx:
export default function LoadingBooks() {
return (
<main className="mx-auto max-w-3xl px-5 py-16" aria-label="正在载入书架">
<div className="h-10 w-40 animate-pulse rounded-xl bg-slate-200" />
<div className="mt-8 h-32 animate-pulse rounded-2xl bg-slate-100" />
</main>
);
}文件名而不是函数名决定等待边界。因为它位于 app/books,所以 /books 以及 /books/[slug] 在异步暂停时都可以使用这份 fallback。组件没有数据输入,输出是标题形状和内容形状的灰色占位块;真实 Page 准备好后,React 会用真实内容替换它,而不是在骨架内部继续追加内容。
animate-pulse 提供等待反馈,rounded-*、宽高和背景色让骨架大致接近最终布局。aria-label 给等待区域一个可理解的名称。骨架不应伪造可点击按钮或真实书名,也不要加入会永远运行的业务计时器;数据很快时看不到 fallback,通常只表示页面在下一帧前已经完成。
占位详情通常完成得太快,肉眼可能看不到骨架。为了确认约定已经生效,在 BookDetailPage 取得 slug 后临时加入:
await new Promise((resolve) => setTimeout(resolve, 1000));Promise 会在一秒后调用 resolve,await 让当前 Server Component 暂停,因此最近的 loading fallback 有机会先发送。它的输入只是固定毫秒数,输出没有业务值,唯一职责是制造可观察的等待窗口。不要把这行放进组件顶层之外,也不要在观察完成后保留,否则每次详情访问都会被人为拖慢。
保存后在新标签页打开 /books/the-long-river,应先看到两块灰色骨架,约一秒后再显示书名。观察完成就删除这行延迟;它只是验证等待边界,不属于项目功能。第 5 课升级骨架后还会用同样方法验收真实数据页面。
创建根级 src/app/not-found.tsx:
import Link from "next/link";
export default function NotFound() {
return (
<main className="mx-auto grid min-h-[65vh] max-w-2xl place-items-center px-5 text-center">
<div>
<p className="text-7xl font-bold">404</p>
<h1 className="mt-4 text-3xl font-bold">这页像一张掉出书里的便签</h1>
<p className
这份组件没有接收错误对象,因为“未找到”是代码主动选择的预期分支。动态 Page 调用 notFound() 后,Next.js 从当前路由段向上寻找最近的 not-found.tsx;目前只有根级文件,所以所有没有更近边界的路由都会复用它。组件输出一个明确标题、解释和返回链接,让用户可以恢复导航。
不要在这里再次调用 notFound(),也不要把服务器异常详情展示给用户。404 只说明请求的资源不存在;文件读取失败、程序错误等未知异常会在第 5 课交给 error.tsx。如果以后希望书籍区域拥有不同文案,可以另建 app/books/not-found.tsx,而不必复制详情 Page。
打开 http://localhost:3000/books/unknown-book,页面应显示“这页像一张掉出书里的便签”,并提供“回到书架”链接。
如果 notFound() 在响应正文开始发送前触发,服务器可以返回 HTTP 404。如果 loading.tsx 或 Suspense fallback 已经开始流式发送,响应头不能再修改,状态会保留 200;Next.js 会在流式 HTML 中加入 <meta name="robots" content="noindex">。部分爬虫把这种“显示未找到但状态为 200”的响应称为软 404。
这不表示缺失内容会被索引,noindex 已明确阻止索引。如果业务、监控或合规要求必须得到 HTTP 404,就要在开始流式发送前确认资源是否存在。
不要写 return notFound(),也不要在调用后继续渲染。notFound() 的返回类型是 never,调用本身会终止当前路由段;写成 if (!exists) notFound() 已经足够。
这一阶段要证明四件事:静态目录能形成页面,动态目录能取得异步参数,Link 能完成站内导航,未知 slug 能进入明确的 404 分支。
保持开发服务器运行,依次打开:
http://localhost:3000/about
http://localhost:3000/books
http://localhost:3000/books/the-long-river
http://localhost:3000/books/unknown-book在详情页点击“← 返回书架”,确认地址回到 /books。在 404 页面点击“回到书架”,也应得到相同结果。
四个地址应分别显示关于内容、两条占位链接、一个带 slug 的占位详情和自定义 404。终端不应出现模块缺失或同步读取 params 的错误。
练习:如果以后把 previewBooks 换成真正的数据查询,哪些路由文件名可以保持不变?