主题
动态路由、并行路由与拦截路由
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} />
}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.tsxtsx
// 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:构建时预生成动态路由的静态页面
下一章讲中间件与路由守卫。