Next.js App Router 深度:路由、布局与数据流
App Router 是 Next.js 的核心,但它的嵌套布局/并行路由/拦截路由/动态段很多人没用透。本文讲清 App Router 的文件约定(layout/page/template/loading/error)、并行路由与拦截路由的高级用法、Server/Client 组件数据流,以及从 Pages Router 迁移的关键差异。
本文要点
- 主题聚焦:Next.js、React
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
很多人把 App Router 当成 Pages Router 的"换皮"——把 pages/ 改名 app/,加个 layout.tsx,就觉得自己会了。这种理解会在第一次写模态、第一次跨段共享状态、第一次想拿到 searchParams 时彻底崩盘。
App Router 不是文件结构的调整,是渲染模型的重写:默认 Server Components、布局真正嵌套且持久化、数据获取从"页面级 getServerSideProps"下沉到"组件级 async"。这篇把路由、布局、数据流和迁移一次性讲透,所有 API 以 Next.js 16 为准。
一、范式转变:从 Pages 到 App,不只是换文件夹
Pages Router 的核心隐喻是"一个文件 = 一个路由 = 一次取数"。pages/blog/[slug].tsx 既负责 URL,又负责 getStaticProps/getServerSideProps,布局靠 _app.tsx 这种全局壳子硬塞,想嵌套就得手写组件层级。
App Router 换了三个底层假设:
- 文件即路由,但路由是"段(segment)"的组合。URL 由目录拼出来,每个目录可以独立携带 UI 约定文件。
- 布局是一等公民,且天然嵌套、天然持久。导航时布局不重渲染,这是 Pages Router 用
_app永远做不到的。 - 组件默认跑在服务端。
page.tsx默认是 Server Component,可以直接await fetch,不需要任何专门的数据获取函数。
一个典型结构长这样:
app/
├── layout.tsx # 根布局,必须含 <html><body>
├── page.tsx # 首页 /
├── blog/
│ ├── layout.tsx # 博客专属布局,嵌套在根布局内
│ ├── page.tsx # /blog
│ └── [slug]/
│ ├── page.tsx # /blog/:slug
│ └── loading.tsx # 该段的 Suspense 兜底
└── (marketing)/ # 路由组,不影响 URL
└── about/page.tsx # /about
记住一句话:在 Pages 里你组织的是"页面",在 App 里你组织的是"布局树 + 叶子页"。这个心智模型的切换比任何 API 都重要。
二、核心文件约定:layout / page / template / loading / error
每个 segment 目录可以放五类特殊文件,它们在组件树里的相对位置是固定的,理解这个层级比背 API 重要:
<Layout> ← layout.tsx:最外层,跨导航持久
<Template> ← template.tsx:每次导航重挂载
<Error> ← error.tsx:错误边界(必须是 Client)
<Loading> ← loading.tsx:Suspense 兜底
<Page /> ← page.tsx:叶子,路由真正的内容
逐个说实战要点:
layout.tsx —— 持久布局,不要乱塞数据。布局在客户端导航时会被复用、不会重渲染,这正是它适合放导航栏、侧边栏的原因。但这也意味着它拿不到 searchParams、拿不到 pathname(拿到也是陈旧的)。需要这些值的组件必须拆成 Client Component(usePathname/useSearchParams)再嵌进去。根布局是唯一必须包含 <html> 和 <body> 的地方,且 <head> 别手写,交给 Metadata API。
page.tsx —— 路由的叶子,唯一让 segment 可访问的文件。没有 page.tsx 的 segment 不算公开路由。它默认是 Server Component。
template.tsx —— 当你需要"每次进入都重置"时用。它和 layout 长得像,但内部被赋了一个基于 segment 的唯一 key,所以导航时会重挂载,内部 Client 状态会重置、useEffect 会重跑。典型场景:表单页(每次进入要清空)、需要每次进入都重新触发动画的页面。一个易错点:template 的 key 跟着当前段的动态参数走,深层段变化不会重挂载上层 template。
loading.tsx —— 自动 Suspense 边界。它会把同段的 page.tsx 及其子布局包进一个 <Suspense fallback={<Loading/>}>。导航是可中断的、共享布局在加载期间保持可交互。但有个大坑:loading.tsx 在层级上位于 layout.tsx 之下,所以布局自己里面的未缓存取数(比如裸 cookies()、未缓存的 fetch)它接不住——要么把这些数据移到 page.tsx,要么自己在 layout 里再套一层 <Suspense>。
error.tsx —— 错误边界,必须是 Client Component。Next.js 16 起,error.tsx 拿到的 props 里推荐用 unstable_retry() 来重新拉取并重渲染出错的子树(老的 reset() 只清状态不重新取数,大多数场景应该用前者)。注意 error.tsx 不包裹同段的 layout,所以布局挂了它救不了——救根布局要用根目录的 global-error.tsx,它得自己写 <html><body>。
三、动态路由:[slug]、catch-all 与 generateStaticParams
动态段三种写法,语义分明:
| 写法 | 匹配 | params 形态 |
|---|---|---|
[slug] | 单段,如 /blog/a | { slug: 'a' } |
[...slug] | catch-all,至少一段,如 /shop/a/b | { slug: ['a','b'] } |
[[...slug]] | optional catch-all,连父路径本身也匹配,如 /shop | { slug: undefined } |
注意 Next.js 15 起,params 是 Promise,16 仍然如此。Server Component 里直接 await,Client Component 里用 React 的 use():
// app/blog/[slug]/page.tsx
export default async function Page(props: PageProps<'/blog/[slug]'>) {
const { slug } = await props.params
const post = await getPost(slug)
return <article>{post.title}</article>
}
这里用的是 Next.js 16 的全局类型助手 PageProps<'/blog/[slug]'>——路由字面量直接给你严格的 params/searchParams 类型,不用手写。LayoutProps<'/dashboard'>、RouteContext<'/api/...'> 同理,next dev / next build / next typegen 时生成。
要预生成静态页,用 generateStaticParams:
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json())
return posts.map((p: { slug: string }) => ({ slug: p.slug }))
}
它返回的段会在构建时静态预渲染;没被列出的段则按需渲染。它同样适用于 Route Handler,能让 /api/posts/[id] 的 GET 在构建时预生成。
四、高级路由:并行路由、拦截路由、路由组
这三件套是 App Router 真正的杀手锏,也是文档看得懂、自己写不出来的部分。
路由组 (group)——纯组织,不影响 URL。用括号包目录,比如 (marketing)/about/page.tsx 仍然是 /about。用途:给一组路由共享一个 layout,又不想污染 URL;或者给同一 URL 段拆出多套根布局。
并行路由 @slot——一个页面,多个独立区域。目录名以 @ 开头(@analytics、@team),它们不是路由段、不出现在 URL 里,而是作为具名 prop 注入到所在 layout:
// app/dashboard/layout.tsx
export default function Layout({
children, analytics, team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<>
{children}
{team}
{analytics}
</>
)
}
它最大的价值是软导航时的局部渲染:点 @team 里的链接只重渲染 @team,@analytics 保持当前激活子页不动。硬导航(刷新)时找不到匹配的 slot 会回落到 default.js,没有就 404。仪表盘、社交 feed 这种"多面板各自独立"的页面,没有并行路由几乎做不优雅。
拦截路由 (.)——把别人的路由"借"进当前布局。用相对段约定:同层 (.)、上一层 (..)、上两层 (..)(..)、从根 (...)。注意它基于路由段,不算文件系统层级——@slot 不计入。
拦截路由最常见的用法是和并行路由配合做可深链接的模态。比如点 feed 里的图片,URL 变成 /photo/123 但弹出模态而非跳页;而别人直接打开 /photo/123 这个分享链接时,走完整 photo 页面:
app/
├── feed/page.tsx
├── photo/[id]/page.tsx # 完整页(分享链接、刷新时走这里)
└── @modal/
├── default.tsx # 返回 null
└── (..)photo/[id]/page.tsx # 拦截 /photo/[id],在模态里渲染
这一套解决了模态的四大难题:URL 可分享、刷新保留上下文、后退关模态、前进重开模态。用 router.back() 关闭,或导航到 slot 内返回 null 的页面来"擦除"模态。
五、布局与状态:什么该持久,什么该重置
App Router 最容易踩的坑是状态边界,因为它和你的直觉相反。
layout.tsx 在导航间持久——这意味着放在 layout 里的 Client Component 的 state(input 值、滚动位置、展开状态)会跨页面保留。这是把双刃剑:做全局购物车、侧边栏折叠态时是福利,做表单时是 bug 源。
判断标准很简单:
- 跨页要留 → 放
layout.tsx(或更上层的 Context Provider) - 进入就清 → 放
page.tsx,或用template.tsx强制重挂载 - 多个 slot 各自独立 → 用并行路由,每个 slot 状态互不干扰
Provider 是另一个高频问题。把全局 Provider(主题、鉴权、Toast)放在根 layout 里没问题,但不要在 layout 里塞太多 Client Provider 包裹 children——每多一层都会让下层 Server Component 的边界变得模糊。原则:Provider 尽量薄、尽量靠上,业务取数留在 page 里。
六、Server/Client 数据流:params 是 Promise
App Router 的数据流核心就一句:Server Component 取数,Client Component 交互。Server 可以直接 await,然后把可序列化的数据当 props 往下传;一旦遇到 useState、onClick、useEffect,就必须 'use client' 切到客户端,切下去之后就不能再 await 了。
page.tsx 和 layout.tsx 都能拿到两个关键 props,在 Next.js 16 里都是 Promise:
params:动态路由段(见第三节)searchParams:URL 查询串,Promise<{ [key]: string | string[] | undefined }>。注意它不是URLSearchParams,是普通对象;用它会让页面进入动态渲染(因为是请求时才能确定的值)
// app/shop/page.tsx
export default async function Page({
searchParams,
}: PageProps<'/shop'>) {
const { page = '1', sort = 'asc' } = await searchParams
const products = await getProducts({ page, sort })
return <ProductGrid items={products} />
}
Client 页面里要用 use(searchParams) 而不是 await。还有几个易错点:
fetch默认去重,同一请求多次调用只发一次,可以放心在 layout 和 page 各取一次。headers()/cookies()是请求时 API,会让组件变动态,且会破坏loading.tsx的兜底(见第二节)——需要时自己套<Suspense>。- Server 不能直接传函数/类实例给 Client,只能传可序列化数据。需要事件回调就把交互组件标
'use client',在它内部用 Server Action 或fetch回服务端。
七、从 Pages Router 迁移:不是平移,是重构
最大的误区是想"逐文件平移"。pages/blog/[slug].tsx 里那套 getStaticProps + getStaticPaths + 顶层布局的组合,在 App Router 里被拆成了 page.tsx(取数)+ generateStaticParams(预生成)+ layout.tsx(布局)三个文件,职责重新切分了。把它当一个重构项目来做,心理预期会健康很多。
迁移的关键差异清单:
getServerSideProps/getStaticProps→ 直接在 Server Component 里await取数getStaticPaths→generateStaticParams_app.tsx全局壳 → 根layout.tsx_document.tsx→ 不需要了,根 layout 写<html><body>,head 用 Metadata APInext/router的useRouter→next/navigation的useRouter、usePathname、useSearchParams、useParams(注意 API 完全不同)getInitialProps→ 别用了,彻底告别
好消息是两者可以共存:app/ 和 pages/ 同时存在,Next.js 给 pages/ 下的路由加一层传统外壳,允许你渐进迁移。建议从叶子路由开始、自底向上迁,每迁一个 segment 就删掉对应的 pages/ 文件,最后再处理根布局。迁移期间 pages/ 的路由优先级更高,别两边都留着同名路由。
小结
- App Router 是渲染模型重写,不是文件换皮。文件即段、布局嵌套持久、组件默认服务端,这三条心智模型先建立,API 才有意义。
- 五类特殊文件的层级是固定的:layout(持久)> template(重挂载)> error(客户端错误边界)> loading(Suspense)> page(叶子)。层级决定了谁能兜住谁——loading 兜不住 layout 自己的未取数。
params/searchParams在 Next.js 16 是 Promise,Server 里await、Client 里use(),配合PageProps<'/route'>拿严格类型。- 并行路由 + 拦截路由 是高级 UI(仪表盘、可深链模态)的正规解法,值得专门花一天练手,别等用到再翻文档。
- 迁移是重构不是平移,职责被重新切分,但
app/与pages/可共存,自底向上渐进迁移最稳。
一句话金句:在 App Router 里,你不是在写页面,你是在组织一棵会持久化的布局树,页面只是它最末端的叶子。 想清楚"这份数据、这段状态、这个 UI 该挂在树的哪一层",比记住任何 API 都重要。
Related