# 动态路由、并行路由与拦截路由
> Next.js 路由系统的三大高级特性——动态路由处理参数化 URL、并行路由在同一页面渲染多个独立视图、拦截路由实现"点击打开 Modal,刷新变完整页"的 Instagram 式体验。本章用大量 AI SaaS 实战场景逐一拆解。
## 1. 动态路由
### 1.1 基础动态段 `[param]`
方括号包裹的目录名表示**动态路由段**——匹配任意单个路径段:
```
app/chat/[id]/page.tsx → /chat/abc params.id = "abc"
app/blog/[slug]/page.tsx → /blog/hello params.slug = "hello"
app/user/[userId]/page.tsx → /user/123 params.userId = "123"
```
```tsx
// app/chat/[id]/page.tsx
type Props = {
params: Promise<{ id: string }>
}
export default async function ChatDetailPage({ params }: Props) {
const { id } = await params
const chat = await db.query.chats.findFirst({
where: eq(chats.id, id),
})
if (!chat) notFound()
return <ChatDetail chat={chat} />
}
```
::: warning params 永远是 string
`params.id` 的类型永远是 `string`,即使 URL 看起来像数字(`/chat/123`)。需要数字时必须手动转换并校验:
```tsx
const numericId = Number(id)
if (Number.isNaN(numericId)) notFound()
```
:::
### 1.2 静态生成动态路由
对于可预知的动态路由(如博客文章),可以在构建时生成静态页面:
```tsx
// app/blog/[slug]/page.tsx
export async function generateStaticParams() {
const posts = await db.query.posts.findMany({
columns: { slug: true },
})
return posts.map((post) => ({
slug: post.slug,
}))
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return <PostContent post={post} />
}
```
构建时,Next.js 会为每个 `slug` 生成一个静态 HTML 文件,首屏加载极快。
### 1.3 多层动态段
```
app/workspace/[workspaceId]/project/[projectId]/page.tsx
→ /workspace/ws-1/project/proj-2
→ params = { workspaceId: "ws-1", projectId: "proj-2" }
```
```tsx
type Props = {
params: Promise<{ workspaceId: string; projectId: string }>
}
export default async function ProjectPage({ params }: Props) {
const { workspaceId, projectId } = await params
// 校验用户对该 workspace 有访问权限
await validateAccess(workspaceId)
const project = await getProject(projectId)
return <ProjectDetail project={project} />
}
```
## 2. Catch-All 与可选 Catch-All
### 2.1 Catch-All 路由 `[...slug]`
匹配**一个或多个**路径段:
```
app/docs/[...slug]/page.tsx
/docs/getting-started → slug = ["getting-started"]
/docs/api/reference → slug = ["api", "reference"]
/docs/api/reference/chat → slug = ["api", "reference", "chat"]
/docs → ❌ 不匹配(至少需要一个段)
```
AI SaaS 中的典型用例——知识库文档路由:
```tsx
// app/docs/[...slug]/page.tsx
type Props = {
params: Promise<{ slug: string[] }>
}
export default async function DocsPage({ params }: Props) {
const { slug } = await params
const path = slug.join('/')
const doc = await getDocument(path)
if (!doc) notFound()
return (
<div className="flex">
<DocsSidebar currentPath={path} />
<article className="prose">
<h1>{doc.title}</h1>
<MDXContent source={doc.content} />
</article>
</div>
)
}
```
### 2.2 可选 Catch-All `[[...slug]]`
匹配**零个或多个**路径段(包括根路径):
```
app/shop/[[...slug]]/page.tsx
/shop → slug = undefined (或 [])
/shop/electronics → slug = ["electronics"]
/shop/electronics/phones → slug = ["electronics", "phones"]
```
适合"分类筛选"页面——根路径显示全部,子路径显示分类:
```tsx
// app/knowledge/[[...category]]/page.tsx
type Props = {
params: Promise<{ category?: string[] }>
}
export default async function KnowledgePage({ params }: Props) {
const { category } = await params
const categoryPath = category?.join('/') ?? null
const documents = categoryPath
? await getDocsByCategory(categoryPath)
: await getAllDocs()
return (
<div>
<h1>{categoryPath ?? '全部知识库'}</h1>
<DocumentGrid documents={documents} />
</div>
)
}
```
### 2.3 对比总结
| 模式 | 语法 | 匹配 /chat | 匹配 /chat/a | 匹配 /chat/a/b |
|------|------|-----------|-------------|---------------|
| 动态段 | `[id]` | ❌ | ✅ `id="a"` | ❌ |
| Catch-All | `[...slug]` | ❌ | ✅ `["a"]` | ✅ `["a","b"]` |
| 可选 Catch-All | `[[...slug]]` | ✅ `undefined` | ✅ `["a"]` | ✅ `["a","b"]` |
## 3. 并行路由(Parallel Routes)
### 3.1 概念
并行路由允许在**同一个布局**中同时渲染**多个独立的页面**。每个并行路由是一个 "slot",用 `@slotName` 目录定义。
```
app/
├── layout.tsx # 接收 children + @analytics + @team
├── page.tsx # 默认 children
├── @analytics/
│ ├── page.tsx # 分析面板
│ └── default.tsx # 回退
└── @team/
├── page.tsx # 团队面板
└── default.tsx # 回退
```
```tsx
// app/layout.tsx
export default function DashboardLayout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div>
<div className="grid grid-cols-2 gap-4">
{analytics}
{team}
</div>
<div className="mt-4">
{children}
</div>
</div>
)
}
```
### 3.2 AI SaaS 实战:仪表盘多面板
```
app/(dashboard)/
├── layout.tsx
├── page.tsx # 主内容
├── @stats/
│ ├── page.tsx # 使用统计(Token 消耗、对话数)
│ ├── loading.tsx # 独立加载态
│ └── default.tsx
├── @recent/
│ ├── page.tsx # 最近对话列表
│ ├── loading.tsx # 独立加载态
│ └── default.tsx
└── @quickActions/
├── page.tsx # 快捷操作面板
└── default.tsx
```
```tsx
// app/(dashboard)/layout.tsx
export default function DashboardLayout({
children,
stats,
recent,
quickActions,
}: {
children: React.ReactNode
stats: React.ReactNode
recent: React.ReactNode
quickActions: React.ReactNode
}) {
return (
<div className="space-y-6 p-6">
{/* 三个面板独立加载,互不阻塞 */}
<div className="grid grid-cols-3 gap-4">
{stats}
{recent}
{quickActions}
</div>
{children}
</div>
)
}
```
关键优势:每个 slot **独立加载、独立报错**。`@stats` 的数据库查询很慢?它自己显示 loading,不影响 `@recent` 和 `@quickActions`。
### 3.3 条件渲染
并行路由的另一个强大用途——根据条件渲染不同的 slot:
```tsx
// app/layout.tsx
import { auth } from '@/lib/auth/config'
export default async function Layout({
children,
admin,
user,
}: {
children: React.ReactNode
admin: React.ReactNode
user: React.ReactNode
}) {
const session = await auth()
const isAdmin = session?.user?.role === 'admin'
return (
<div>
{isAdmin ? admin : user}
{children}
</div>
)
}
```
### 3.4 default.tsx 的重要性
并行路由**必须**为每个 slot 提供 `default.tsx`,否则导航到子路由时会报错:
```tsx
// app/@analytics/default.tsx
export default function Default() {
return null
}
```
为什么需要?当用户导航到一个子路由(比如 `/chat/123`),而 `@analytics` 目录下没有 `chat/[id]/page.tsx`,Next.js 需要知道这个 slot 显示什么。`default.tsx` 就是这个回退。
## 4. 拦截路由(Intercepting Routes)
### 4.1 概念
拦截路由实现了一个优雅的 UX 模式:
- **点击链接** → 在当前页面打开 Modal(不离开当前页面)
- **直接访问 URL / 刷新** → 显示完整独立页面
这正是 Instagram、Twitter 图片查看的交互模式。
### 4.2 拦截约定
```
(.)/folder → 拦截同级路由
(..)/folder → 拦截上一级路由
(..)(..)/folder → 拦截上两级
(...)/folder → 拦截根级路由
```
### 4.3 实战:AI 对话预览 Modal
目录结构:
```
app/(dashboard)/
├── layout.tsx # 接收 children + @modal
├── @modal/
│ ├── default.tsx # 默认不显示 modal
│ └── (.)chat/[id]/
│ └── page.tsx # 拦截 /chat/:id → 显示为 modal
├── chat/
│ ├── page.tsx # /chat 列表页
│ └── [id]/
│ └── page.tsx # /chat/:id 完整页面(直接访问时)
```
```tsx
// app/(dashboard)/layout.tsx
export default function DashboardLayout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}
```
```tsx
// app/(dashboard)/@modal/default.tsx
export default function Default() {
return null // 没有 modal 时不渲染任何东西
}
```
```tsx
// app/(dashboard)/@modal/(.)chat/[id]/page.tsx — Modal 版本
import { Dialog, DialogContent } from '@/components/ui/dialog'
import { ChatPreview } from '@/components/chat/chat-preview'
type Props = {
params: Promise<{ id: string }>
}
export default async function ChatModal({ params }: Props) {
const { id } = await params
const chat = await getChat(id)
return (
<Dialog open>
<DialogContent className="max-w-2xl">
<ChatPreview chat={chat} />
</DialogContent>
</Dialog>
)
}
```
```tsx
// app/(dashboard)/chat/[id]/page.tsx — 完整页面版本
type Props = {
params: Promise<{ id: string }>
}
export default async function ChatPage({ params }: Props) {
const { id } = await params
const chat = await getChatWithMessages(id)
return (
<div className="mx-auto max-w-4xl">
<ChatHeader chat={chat} />
<ChatMessages messages={chat.messages} />
<ChatInput chatId={id} />
</div>
)
}
```
用户体验:
```
在 /chat 列表页点击某个对话:
→ URL 变为 /chat/abc
→ 列表页不消失,上面弹出 Modal 显示对话预览
→ 用户可以关闭 Modal 回到列表
直接访问 /chat/abc(或刷新页面):
→ 显示完整的对话页面(不是 Modal)
→ 包含消息列表、输入框等完整功能
```
### 4.4 拦截路由 + 并行路由组合模式
这是 Next.js 路由系统最高级的组合——也是生产项目中最常用的 Modal 方案:
```
核心原理:
1. @modal 是并行路由(slot)
2. (.)chat/[id] 是拦截路由
3. 两者组合 = 点击链接打开 Modal,刷新变完整页
```
## 5. 实战场景速查
| 场景 | 路由模式 | 目录结构 |
|------|---------|---------|
| 对话详情 | 动态路由 | `chat/[id]/page.tsx` |
| 知识库文档 | Catch-All | `docs/[...slug]/page.tsx` |
| 分类筛选 | 可选 Catch-All | `knowledge/[[...category]]/page.tsx` |
| 仪表盘多面板 | 并行路由 | `@stats/` + `@recent/` + `@quickActions/` |
| 对话预览弹窗 | 拦截 + 并行 | `@modal/(.)chat/[id]/page.tsx` |
| 图片灯箱 | 拦截 + 并行 | `@modal/(.)photo/[id]/page.tsx` |
| 多租户 | 动态路由 | `[workspace]/chat/page.tsx` |
| API 版本化 | 动态路由 | `api/v[version]/chat/route.ts` |
## 本章小结
- **动态路由** `[param]`:匹配单个路径段,`params` 永远是 string
- **Catch-All** `[...slug]`:匹配一个或多个段;**可选 Catch-All** `[[...slug]]`:匹配零个或多个
- **并行路由** `@slot`:同一布局渲染多个独立页面,独立加载、独立报错,必须有 `default.tsx`
- **拦截路由** `(.)`:点击打开 Modal,刷新变完整页——与并行路由组合是最强 Modal 方案
- **`generateStaticParams`**:构建时预生成动态路由的静态页面
下一章讲中间件与路由守卫。