上一章里,我们已经创建了一个能运行的 Next.js 项目,也知道 app/page.tsx 对应首页。现在该把这条结论展开了:为什么有些文件夹会进入 URL,有些不会?layout.tsx、loading.tsx 和 error.tsx 明明放在同一个目录里,为什么职责完全不同?方括号、圆括号、下划线和 @ 又各自在告诉路由器什么?
理解 App Router,最有效的方法不是背一张“特殊文件清单”,而是始终分清三件事:目录产生路由段,特殊文件赋予路由段能力,浏览器导航决定这些能力怎样组合。 我们会沿着这条线从普通路由走到动态路由,再进入路由组、并行路由和拦截路由。

从文件树到访问路径:文件夹形成路由段,page.tsx 公开页面入口,layout.tsx 提供共享外壳。
这一章只讨论 App Router。搜索资料时如果看到 pages/、_app.tsx、getStaticProps 或 getServerSideProps,那通常属于 Pages Router。两套路由器可以出现在同一个迁移中的项目里,但文件约定和数据 API 不能随意混用。
读完这一章,你应该能拿到一棵陌生的 app 文件树,推导公开 URL、布局嵌套与参数形状;也能解释页面加载、资源不存在、运行时错误、并行槽和模态路由分别由哪一层处理。
App Router 把 URL 按斜杠拆成一段一段。/blog/hello 有 blog 和 hello 两个路径段;在最直接的目录结构里,它们对应 app/blog/hello/ 两层文件夹。Next.js 把每一层称为 route segment,也就是路由段。
不过,“存在文件夹”和“存在公开页面”不是一回事。一个目录只有普通组件或工具文件时,浏览器不能直接访问它。加入 page.tsx 后,这个路由段才公开为页面;加入 route.ts 后,它才公开为处理 HTTP 请求的 Route Handler。
先看一棵没有高级语法的文件树:
app/
├── layout.tsx
├── page.tsx
├── about/
│ └── page.tsx
└── blog/
├── layout.tsx
├── page.tsx
└── hello/
└── page.tsx它能推导出三条页面 URL:
这里有两个容易忽略的细节。第一,文件名 page 不会出现在 URL 中,它表示“当前段的页面”。第二,layout.tsx 不会额外产生路径,它只是包裹当前段及其后代。
page.tsx 必须默认导出一个 React 组件。页面默认是 Server Component,因此一个完全静态的介绍页可以很简单:
// app/about/page.tsx
export default function AboutPage() {
return (
<main>
<h1>关于学习站</h1>
<p>这里记录我们的 Next.js 学习路径。</p>
</main>
)
}如果只有 app/about/team-card.tsx,没有 app/about/page.tsx 或 route.ts,/about 不会因为这个组件存在而自动公开。普通文件可以安全地和路由文件放在一起,这一点后面还会用来组织大型项目。
Route Handler 使用 Web Request 和 Response API,适合 JSON、Webhook、文件或自定义 HTTP 响应:
// app/api/health/route.ts
export async function GET() {
return Response.json({ ok: true })
}访问 /api/health 会得到 JSON。它不会套用 layout.tsx,也不参与页面的客户端导航。page.tsx 和 route.ts 也不能占据同一个路由段,因为二者都会接管该 URL 的 HTTP 方法。
app/profile/page.tsx + app/profile/route.ts → 冲突
app/profile/page.tsx + app/api/profile/route.ts → 可以共存下面的实验室把文件夹、特殊文件、URL 模式和 params 放到同一块画布上。先点一键场景,再故意制造一个冲突,观察究竟是哪一部分改变了 URL。
拿到一棵文件树时,可以按下面的顺序读,不必凭感觉猜:
先从 app 或 src/app 开始,只沿着目标文件的父目录向上走。普通文件名不参与 URL,目录才可能形成路由段。
再识别圆括号、下划线、@slot 和拦截标记。它们会改变组织方式或渲染方式,不能像普通目录那样直接抄进 URL。
找到叶子处的 page.tsx 或 route.ts。没有这类公开入口时,即使目录层级完整,也没有可访问的页面或 HTTP 端点。
最后从叶子向根部回看 、、、 和 ,确定页面会被哪些边界包裹。
路由只是项目结构的一部分。真实项目还会有静态资源、共享组件、环境变量和配置文件。Next.js 没有强迫团队采用唯一的业务目录结构,但它对几个顶层位置有明确约定。
nextjs-notes/
├── app/ # App Router,也可以整体移到 src/app
├── public/ # 直接从网站根路径提供的静态资源
├── components/ # 团队自定义名称,不参与路由
├── lib/ # 团队自定义名称,不参与路由
├── next.config.ts # Next.js 配置
├── package.json # 依赖和脚本
├── tsconfig.json # TypeScript 与路径别名
├── eslint.config.mjs # 代码检查配置
└── .env.local # 本地环境变量,不提交版本库src/ 是可选的源码容器。采用它时,应把整棵路由目录移动到 src/app,而不是复制一份:
nextjs-notes/
├── src/
│ ├── app/
│ ├── components/
│ └── lib/
├── public/
├── package.json
├── next.config.ts
└── tsconfig.jsonpublic、配置文件和 .env.* 仍留在项目根目录。如果根目录已经有 app,src/app 会被忽略;根目录有 pages 时,src/pages 同理。看到路由“明明写了却没有生效”,先检查是不是同时保留了两棵目录。
public/manual.pdf 对应 /manual.pdf,public/images/logo.png 对应 /images/logo.png。不要写成 /public/images/logo.png。public 资源是静态文件,不会被 layout.tsx 包裹,也不会因为位于某个业务目录旁边而获得动态路由能力。
框架不会给根目录的 components、lib、features 或 utils 赋予特殊含义。你可以把全站共享组件放在根部,也可以按功能贴近路由共置。比“哪种目录名最标准”更重要的是依赖方向清楚:页面组合功能,功能使用基础组件,基础组件不要反过来导入具体页面。
.next/ 是开发和构建过程生成的目录,不是源码。不要在里面修改页面,也不要把它当作静态资源目录。修改后看似生效的内容,会在下一次编译时被覆盖。
同一个路由段可以放置一组约定文件。它们不是互相替代的页面模板,而是从外向内形成边界:布局负责共享外壳,模板控制重新挂载,错误和加载文件提供恢复或等待界面,页面位于最内层。

一个路由段内的特殊文件会形成逐层嵌套的 UI 边界;error.tsx 是客户端错误边界,根布局必须包含 html 与 body。
可以先用一张表抓住职责:
根布局通常位于 app/layout.tsx。它是整棵 App Router 树的最外层,必须返回 <html> 和 <body>:
// app/layout.tsx
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'Next.js 学习站',
description: '记录 App Router 的学习过程',
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
子目录也能有布局。访问 /dashboard/settings 时,根布局先包住 app/dashboard/layout.tsx,后者再包住设置页面。客户端导航期间,共享布局可以被复用,因此布局里的客户端子组件状态和 DOM 不会仅仅因为子页面变化就被全部重置。
布局不能直接接收会随每次查询字符串变化的 searchParams。布局会被复用,那样的数据可能变旧。需要查询参数时,在页面使用 searchParams prop,或在 Client Component 中使用 useSearchParams。
模板也接收 children:
// app/dashboard/template.tsx
export default function DashboardTemplate({
children,
}: {
children: React.ReactNode
}) {
return <section className="dashboard-transition">{children}</section>
}Next.js 会给模板一个和当前段相关的 key。这个段或它的动态参数变化时,模板子树会重新挂载,内部 Client Component state 会重置,useEffect 会重新同步。进入更深的子段不会让更高层模板无条件重挂载,单纯改变查询参数也不会触发模板重挂载。
所以不要为了“代码更整齐”同时创建一模一样的布局和模板。需要持久共享 UI 时用布局;明确需要重置表单、动画或副作用时才加模板。
// app/dashboard/loading.tsx
export default function Loading() {
return (
<main aria-busy="true" aria-live="polite">
<h1>正在准备仪表板</h1>
<p>数据返回后,这里会替换为真实内容。</p>
</main>
)
}同目录的 loading.tsx 会在 layout.tsx 内部,自动包裹 page.tsx、not-found.tsx 和更深的子布局。后备界面可以被预取,导航也可以被中断;等待新内容时,共享布局仍可交互。
它不会包住同一段的 layout.tsx、template.tsx 或 error.tsx。如果布局自己读取了未缓存的运行时数据,不能指望同段 loading.tsx 为它兜底,应把等待点移到页面,或在布局内部建立更精确的 <Suspense> 边界。
当前版本的 error.tsx 优先提供 unstable_retry(),它会尝试重新取数并重新渲染错误边界的子树:
// app/dashboard/error.tsx
'use client'
import { useEffect } from 'react'
export default function Error({
error,
unstable_retry,
}: {
error: Error & { digest?: string }
unstable_retry: () => void
}) {
useEffect(() => {
console.error
旧版本资料常使用 reset()。迁移项目时要按已安装版本的类型提示选择,不要把两个时代的 props 混进同一个示例。
error.tsx 会包住同段的 loading、not-found、page 和子布局,却不包同段的 layout 与 template。同段布局抛出的错误要向父段错误边界冒泡;根布局或根模板的错误则由 app/global-error.tsx 处理。
用户请求了格式正确但不存在的文章,这不是服务器崩溃,而是一个预期的 404 分支。页面取数后调用 notFound(),Next.js 会终止该段渲染并寻找最近的 not-found.tsx:
// app/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
type Post = {
slug: string
title: string
}
const posts: Post[] = [
{ slug: 'routing', title: '读懂 App Router' },
]
export default async function BlogPostPage({
params,
}:
// app/blog/[slug]/not-found.tsx
import Link from 'next/link'
export default function PostNotFound() {
return (
<main>
<h1>没有找到这篇文章</h1>
<Link href="/blog">返回文章列表</Link>
</main>
)
}根部的 app/not-found.tsx 还能处理整站没有匹配到的 URL。不要用 notFound() 表达权限不足、表单验证失败或数据库暂时不可用;这些情况有各自更准确的状态与恢复路径。
下面的控制台把这些边界做成可观察的状态机。重点试两组对比:切换团队参数与进入更深子路由;触发页面错误与触发同段布局错误。
静态目录适合已知路径,但文章 slug、商品 id 和文档层级通常来自数据。动态路由段用方括号表示,括号写法决定它匹配几段 URL,也决定 params 中的值是什么类型。

三种动态路由写法决定 URL 的匹配范围,也决定 params.slug 是字符串、字符串数组还是 undefined。
app/blog/[slug]/page.tsx 匹配 /blog/routing,但不会匹配 /blog/2026/routing。单个 [slug] 恰好吃掉一个路径段:
// app/blog/[slug]/page.tsx
export default async function BlogPostPage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
return <h1>当前文章:{slug}</h1>
}一个路由中可以有多个动态段:
app/shop/[category]/[product]/page.tsx访问 /shop/books/nextjs-guide 时,结果是:
{
category: 'books',
product: 'nextjs-guide',
}[...slug] 会把后续路径收集成字符串数组:
// app/docs/[...slug]/page.tsx
export default async function DocsPage({
params,
}: {
params: Promise<{ slug: string[] }>
}) {
const { slug } = await params
return <p>当前文档:{slug.join(' / ')}</p>
}[[...slug]] 在 catch-all 外再加一层方括号,使参数整体可选。它既匹配 /docs,也匹配 /docs/app/routing:
// app/docs/[[...slug]]/page.tsx
export default async function DocsPage({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug } = await params
if (!slug) {
return <h1>文档首页</h1>
}
return
访问 /docs 时,slug 是 undefined;访问 /docs/a 时是 ['a']。不要把数组直接插进数据库查询,也不要假设它永远存在,先根据路由写法缩小类型。
在当前 App Router 中,页面、布局、Route Handler 和 default.tsx 收到的 params 都是 Promise。Server Component 使用 await 解包;Client Component 不能写成 async 渲染函数,可以用 React 的 use():
// app/blog/[slug]/client-title.tsx
'use client'
import { use } from 'react'
export default function ClientTitle({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = use(params)
return <p>浏览器中的文章标识:{slug}</p>
}多数页面不需要为了读参数变成 Client Component。更常见的做法是在服务器页面 await params,再把已解析的字符串传给真正需要交互的小组件。
页面的 searchParams 同样是 Promise,而且是普通对象,不是 URLSearchParams 实例:
// app/shop/page.tsx
type ShopQuery = {
page?: string
sort?: string
tag?: string | string[]
}
export default async function ShopPage({
searchParams,
}: {
searchParams: Promise<ShopQuery>
}) {
const { page =
动态路径参数和查询参数不要混淆:/blog/hello?page=2 的 slug 来自 params,page 来自 searchParams。
类型生成后,当前 Next.js 还提供全局 PageProps、LayoutProps 和 RouteContext 辅助类型。它们不需要手动导入:
// app/blog/[slug]/page.tsx
export default async function Page(
props: PageProps<'/blog/[slug]'>
) {
const { slug } = await props.params
const query = await props.searchParams
return (
<pre>{JSON.stringify({ slug, query }, null, 2)}</pre>
路由字面量能让编辑器对参数键提供更严格的提示。团队若尚未生成这些类型,先使用显式的 Promise<{ ... }> 也完全可以;关键是类型要和括号形状一致。
旧教程经常直接写 params.slug。同步访问在过渡版本中曾为兼容而保留,但会被淘汰。新代码统一 await params,可以避免升级时再做一次全局迁移。
目录变多以后,URL 层级和代码组织不总是一致。营销页、商店和后台可能属于不同团队,却仍希望保留简洁地址;路由旁边的组件和数据文件也不应该全部进入 URL。App Router 为这些需求提供了几种不同机制。

组织目录不等于改变 URL:路由组不进入 URL,私有目录退出路由系统,普通文件可以与页面安全共置;根 app 与 src/app 不可同时生效,同一路径也不能由两个路由组重复占用。
(marketing) 这样的目录用于分类、共享布局或划分团队,但圆括号中的名字不会进入 URL:
app/
├── (marketing)/
│ ├── layout.tsx
│ ├── page.tsx # /
│ └── pricing/
│ └── page.tsx # /pricing
└── (shop)/
├── layout.tsx
└── cart/
└── page.tsx # /cart路由组常用于让一部分同级页面共享布局,或建立多个根布局。如果删除顶层 app/layout.tsx,让 (marketing)/layout.tsx 和 (shop)/layout.tsx 分别成为根布局,那么每个根布局都必须包含 <html> 与 <body>。跨不同根布局导航会发生完整页面加载,而不是普通的客户端局部切换。
圆括号不进入 URL,也意味着它不能替你解决同路径冲突:
app/(marketing)/about/page.tsx → /about
app/(shop)/about/page.tsx → /about这两条路径无法同时存在。路由组是组织工具,不是 URL 命名空间。
_components、_lib 这样的私有目录会让整棵子目录退出路由系统:
app/blog/
├── _components/
│ ├── post-card.tsx
│ └── post-list.tsx
├── _lib/
│ └── posts.ts
├── [slug]/
│ └── page.tsx
└── page.tsx普通文件本来就不会自动公开,所以私有目录不是安全共置的必要条件。它的价值在于向团队明确表达“这里是实现细节”,并避免未来特殊文件命名冲突。
如果业务 URL 真的需要以下划线开头,可以把目录名写成 URL 编码形式 %5Ffolder。直接使用 _folder 会被当作私有约定。
下面的 post-list.tsx 和 posts.ts 都不会自动成为 URL:
app/blog/
├── page.tsx
├── post-list.tsx
├── posts.ts
└── blog.module.css这让团队有三种常见策略:
app 只保存路由文件,把组件和工具放到根目录。app 顶层建立共享 _components 与 _lib。Next.js 不偏爱其中某一种。小项目从简单结构开始,等同一功能确实有多种文件时再建目录,比一开始制造十层抽象更容易维护。
普通嵌套路由是一条从根到叶子的链。仪表板或社交照片流有时需要在同一布局里同时渲染几棵可独立导航的子树,或者让详情既能作为独立页面打开,又能在当前列表上方显示成模态框。这时才轮到 @slot 和拦截标记。
这些是针对具体交互模型的高级约定。普通详情页不需要为了“显得完整”改成并行路由。

并行路由让同一个 layout.tsx 同时接收 children、team 和 analytics;软导航会保留其他槽的活动状态,硬刷新时无法恢复的槽需要 default.tsx 兜底。
app/dashboard/
├── @analytics/
│ ├── default.tsx
│ └── page.tsx
├── @team/
│ ├── default.tsx
│ └── page.tsx
├── layout.tsx
└── page.tsx@analytics 与 @team 是 slots,不是 URL 路由段。app/dashboard/page.tsx 则对应一个无需目录声明的隐式 children 槽。父布局按去掉 @ 的名字接收它们:
// app/dashboard/layout.tsx
export default function DashboardLayout({
children,
team,
analytics,
}: {
children: React.ReactNode
team: React.ReactNode
analytics: React.ReactNode
}) {
return (
<main>
<section>{children}</section
软导航时,Next.js 会追踪每个槽当前激活的子页面。切换一个槽时,其他槽可以保留原状态。浏览器刷新或直接输入地址属于硬导航,服务器无法恢复客户端之前保存的未匹配槽状态,于是会读取该槽的 default.tsx。
// app/dashboard/@analytics/default.tsx
export default function AnalyticsDefault() {
return <p>请选择一个分析视图</p>
}当前规则下,具名槽硬加载时缺少所需 default.tsx 会产生错误;隐式 children 无法恢复且没有 default.tsx 时会进入 404。设计并行路由时,不要只测试站内点击,还要逐条刷新可分享 URL。
同一层槽的静态与动态形状也要保持一致。如果一个槽在该层是动态的,其他槽不能在同层混用静态形状来制造含糊匹配。
拦截路由解决的是“同一个 URL,两种打开上下文”。从照片流点击 /photo/42 时,用户希望保留照片流并看到模态框;复制这个 URL 到新标签页时,又希望得到完整详情页。

并行槽与拦截式模态框:站内软导航保留 feed 布局并由 @modal 槽覆盖照片 42,直接访问或刷新则渲染照片 42 完整页。
一种典型结构是:
app/
├── @modal/
│ ├── (.)photo/
│ │ └── [id]/
│ │ └── page.tsx
│ └── default.tsx
├── feed/
│ └── page.tsx
├── photo/
│ └── [id]/
│ └── page.tsx
└── layout.tsx(.)photo 表示拦截同一路由段层级的 photo。标记的含义是:
这里数的是 路由段,不是文件系统层级。@modal 是槽位,不进入 URL,也不增加回退层级;路由组同样不能简单当作 URL 段去数。
站内通过 <Link href="/photo/42"> 软导航时,拦截文件把详情渲染进 modal 槽,地址栏仍是可分享的 /photo/42。刷新后没有旧照片流上下文,标准 app/photo/[id]/page.tsx 会渲染完整页面。
下面的实验可以直接观察地址栏、历史栈、children 与 @modal 的变化。先点照片打开模态框,再刷新同一个地址;随后临时移除 default.tsx,重复硬加载。
并行路由和拦截路由经常一起出现,但它们解决不同问题:@slot 让一个布局接收多棵子树,拦截标记决定站内导航时从哪里呈现目标路由。只需要其中一个能力时,不必强行把两者绑定。
文件系统路由的优势是可视化,但错误也常直接写在目录名里。遇到构建或访问异常时,先把相关文件路径列出来,逐段推导 URL,比反复重启开发服务器更快。
静态段会比动态段更具体,因此 /products/new 可以由 app/products/new/page.tsx 处理,而 /products/42 由 app/products/[id]/page.tsx 处理。但不要因此在同一位置堆出多条形状相同的动态路由:[id] 和 [slug] 只是参数名不同,对 URL 匹配器来说都是一段未知字符串。
Catch-all 应留给真正需要任意深度路径的内容,例如文档树或兼容旧地址。把所有页面都塞进 [[...slug]] 会让边界、类型和错误定位变得模糊。
有人会尝试让 page.tsx 负责 GET 页面,让同段 route.ts 负责 POST 表单。这样仍会冲突,因为页面和 Route Handler 都接管该路由的 HTTP 动词集合。可以把处理器移到 /api/profile,或者根据业务采用 Server Function,而不是让两个入口争夺同一 URL。
“数据库返回空结果”通常可以转为 notFound();“数据库连接断开”是意外异常,应由错误边界和日志系统处理。把所有异常都转成 404 会让用户误以为资源不存在,也会掩盖真正的服务故障。
排查一条异常路由时,可以复用下面这套顺序:
写下用户实际访问的完整地址,把查询字符串暂时分离,只保留 pathname。
找到候选 page.tsx 或 route.ts,从 app 向下逐段转换目录名,明确哪些段被省略、捕获或拦截。
对照方括号形状检查 params 类型,再确认当前代码是否异步解包 Promise。
从叶子向根部列出布局、模板、加载、错误和未找到边界,判断异常发生在边界内部还是外部。
现在给学习站加一组真正能检验目录设计的路由。目标不是把所有特殊文件都用一遍,而是让每一个文件都能回答明确问题。
你需要实现:
/ 是首页,使用全站根布局。/pricing 属于 (marketing) 路由组,但 URL 不出现 marketing。/docs 与任意深度的 /docs/... 由一条 Optional Catch-all 路由处理。/products/[id] 显示产品;无效 id 调用 notFound()。/api/health 返回 JSON,不能和页面入口冲突。loading.tsx、error.tsx 与 not-found.tsx,并能说清三者语义。params 写法。先自己画目录,再展开一种参考结构。
离开这一页前,不需要默写所有文件名,但应该能用自己的话解释这些判断:
page.tsx 或 route.ts 才让当前段成为公开入口。<html> 与 <body>;嵌套布局包裹后代并在导航中复用。loading、error 与 not-found 分别表达等待、意外异常和资源不存在,边界范围并不相同。[slug] 得到字符串,[...slug] 得到至少一项的数组,[[...slug]] 还允许 undefined。params 与页面 searchParams 都按 Promise 处理。@slot 让布局并行接收多棵子树,硬加载时要考虑 default.tsx。如果其中任何一条还只能靠背诵,回到对应交互页再操作一次。下一节会把页面与布局单独放大,继续讨论共享 UI、嵌套关系和导航时的实际生命周期。
layout.tsxtemplate.tsxloading.tsxerror.tsxnot-found.tsx最后分别测试站内软导航和地址栏硬加载。并行槽、拦截路由与多根布局在两种导航下可能出现不同结果。
文档路由要显式处理 undefined:
// app/docs/[[...slug]]/page.tsx
export default async function DocsPage({
params,
}: {
params: Promise<{ slug?: string[] }>
}) {
const { slug } = await params
const path = slug?.join('/') ?? 'index'
return (
<main>
<h1>文档中心</h1>
<p>当前文档键:{path}</p>
</main>
)
}最后在浏览器中分别检查 /docs、/docs/app/routing、/products/keyboard、/products/missing 和 /api/health。页面点击通过后,再把这些地址逐个粘贴到新标签页,确认硬加载结果一致。