App Router 目录结构深度解析
App Router 是 Next.js 的灵魂。它用文件系统定义路由、用特殊文件名控制行为——
page.tsx定义页面、layout.tsx定义布局、loading.tsx定义加载态、error.tsx定义错误边界。理解这套约定,就理解了 Next.js 的一半。本章逐一拆解每个特殊文件的作用、渲染时机和嵌套规则。
1. 目录约定:文件即路由
1.1 核心规则
App Router 的路由规则可以用一句话概括:app/ 目录下的 page.tsx 文件定义一个可访问的路由。
app/
├── page.tsx → /
├── about/
│ └── page.tsx → /about
├── blog/
│ ├── page.tsx → /blog
│ └── [slug]/
│ └── page.tsx → /blog/:slug(动态路由)
├── chat/
│ ├── page.tsx → /chat
│ └── [id]/
│ └── page.tsx → /chat/:id
└── api/
└── chat/
└── route.ts → API: /api/chat
关键认知:不是所有 app/ 下的目录都会生成路由——只有包含 page.tsx 的目录才会。这意味着你可以在 app/ 下放 components/、lib/ 等辅助文件,它们不会变成路由。
1.2 页面 vs 路由 vs API
| 文件 | 作用 | 访问方式 |
|---|---|---|
page.tsx | 页面组件 | 浏览器访问 URL |
route.ts | API 路由 | HTTP 请求(GET/POST 等) |
layout.tsx | 布局包裹 | 不直接访问,自动包裹子页面 |
app/chat/page.tsx 和 app/chat/route.ts 不能同时存在——一个路径要么是页面,要么是 API,不能两者兼具。
2. 七种特殊文件详解
2.1 page.tsx — 页面入口
每个路由的唯一入口组件。默认是 Server Component:
// app/chat/page.tsx
import { db } from '@/lib/db'
// 类型定义:Next.js 16 中 params 和 searchParams 都是 Promise
type Props = {
params: Promise<{ id: string }>
searchParams: Promise<{ tab?: string }>
}
export default async function ChatPage({ searchParams }: Props) {
const { tab } = await searchParams
const chats = await db.query.chats.findMany({
orderBy: (chats, { desc }) => [desc(chats.updatedAt)],
})
return (
<div>
<h1>AI 对话</h1>
<ChatList chats={chats} activeTab={tab} />
</div>
)
}2.2 layout.tsx — 布局组件
布局组件包裹其下所有页面和子布局。关键特性:导航时布局不会重新渲染(状态保持)。
// app/layout.tsx — 根布局(必须存在)
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: { default: 'AI SaaS Platform', template: '%s | AI SaaS' },
description: 'AI 全栈应用平台',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body className={inter.className}>
<Providers>
{children}
</Providers>
</body>
</html>
)
}嵌套布局是 Next.js 的杀手级设计——仪表盘页面自动嵌套侧边栏:
// app/(dashboard)/layout.tsx — 仪表盘布局
import { Sidebar } from '@/components/layout/sidebar'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex h-screen">
<Sidebar />
<main className="flex-1 overflow-auto p-6">
{children}
</main>
</div>
)
}渲染时,页面组件被层层包裹:
RootLayout
└── DashboardLayout
└── ChatPage
2.3 loading.tsx — 加载态
当 page.tsx 中有异步数据获取时,loading.tsx 会作为 Suspense fallback 自动显示:
// app/(dashboard)/chat/loading.tsx
import { Skeleton } from '@/components/ui/skeleton'
export default function ChatLoading() {
return (
<div className="space-y-4">
<Skeleton className="h-8 w-48" />
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
</div>
)
}底层等价于:
<Suspense fallback={<ChatLoading />}>
<ChatPage />
</Suspense>2.4 error.tsx — 错误边界
捕获子树中的运行时错误,必须是客户端组件(因为需要交互——重试按钮):
// app/(dashboard)/chat/error.tsx
'use client'
import { useEffect } from 'react'
import { Button } from '@/components/ui/button'
export default function ChatError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('Chat Error:', error)
}, [error])
return (
<div className="flex flex-col items-center justify-center gap-4 py-20">
<h2 className="text-xl font-semibold">出错了</h2>
<p className="text-muted-foreground">{error.message}</p>
<Button onClick={reset}>重试</Button>
</div>
)
}2.5 not-found.tsx — 404 页面
// app/not-found.tsx — 全局 404
import Link from 'next/link'
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center">
<h1 className="text-6xl font-bold">404</h1>
<p className="mt-4 text-muted-foreground">页面不存在</p>
<Link href="/" className="mt-6 text-primary underline">
回到首页
</Link>
</div>
)
}也可以在页面中手动触发:
import { notFound } from 'next/navigation'
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() // 触发最近的 not-found.tsx
return <ChatDetail chat={chat} />
}2.6 template.tsx — 模板组件
和 layout.tsx 类似,但每次导航都会重新挂载(不保持状态):
// app/(dashboard)/template.tsx
export default function DashboardTemplate({ children }: { children: React.ReactNode }) {
// 每次导航到仪表盘下的任何页面,这个组件都会重新挂载
// 适合做:页面级动画、导航日志、每次进入页面时的初始化
return (
<div className="animate-in fade-in duration-300">
{children}
</div>
)
}| 特性 | layout.tsx | template.tsx |
|---|---|---|
| 导航时重新挂载 | ❌ 不会 | ✅ 会 |
| 状态保持 | ✅ 保持 | ❌ 重置 |
| 适用场景 | 侧边栏、导航栏 | 进场动画、页面埋点 |
2.7 default.tsx — 并行路由回退
用于并行路由(Parallel Routes)中,当某个 slot 没有匹配到内容时的回退:
// app/@modal/default.tsx
export default function Default() {
return null // 没有 modal 时显示空
}3. Route Groups 与特殊目录
3.1 Route Groups — (folderName)
用圆括号包裹的目录不会出现在 URL 中,纯粹用于组织代码和共享布局:
app/
├── (marketing)/ # URL 中无 "marketing"
│ ├── layout.tsx # 营销页布局(Header + Footer)
│ ├── page.tsx # → /
│ └── pricing/
│ └── page.tsx # → /pricing(不是 /marketing/pricing)
├── (dashboard)/ # URL 中无 "dashboard"
│ ├── layout.tsx # 仪表盘布局(Sidebar)
│ ├── chat/
│ │ └── page.tsx # → /chat
│ └── settings/
│ └── page.tsx # → /settings
3.2 动态路由 — [param]
app/chat/[id]/page.tsx → /chat/abc → params.id = "abc"
app/blog/[...slug]/page.tsx → /blog/a/b/c → params.slug = ["a","b","c"]
app/shop/[[...slug]]/page.tsx → /shop 或 /shop/a/b → 可选 catch-all
3.3 并行路由 — @slotName
允许在同一布局中同时渲染多个页面:
app/
├── layout.tsx # 接收 children + @modal
├── page.tsx # 主内容
├── @modal/
│ ├── default.tsx # 默认回退
│ └── (.)chat/[id]/
│ └── page.tsx # 拦截 /chat/:id 显示为 modal
// app/layout.tsx
export default function Layout({
children,
modal,
}: {
children: React.ReactNode
modal: React.ReactNode
}) {
return (
<>
{children}
{modal}
</>
)
}3.4 私有目录 — _folderName
以下划线开头的目录会被排除在路由系统之外:
app/
├── _components/ # 不会生成路由
│ └── chat-input.tsx
├── _lib/ # 不会生成路由
│ └── utils.ts
└── chat/
└── page.tsx # 正常路由
4. 特殊文件的嵌套与优先级
4.1 渲染嵌套顺序
// 一个页面的完整渲染树(从外到内)
<RootLayout> {/* app/layout.tsx */}
<DashboardLayout> {/* app/(dashboard)/layout.tsx */}
<DashboardTemplate> {/* app/(dashboard)/template.tsx */}
<ErrorBoundary> {/* app/(dashboard)/chat/error.tsx */}
<Suspense fallback={ {/* app/(dashboard)/chat/loading.tsx */}
<ChatLoading />
}>
<ChatPage /> {/* app/(dashboard)/chat/page.tsx */}
</Suspense>
</ErrorBoundary>
</DashboardTemplate>
</DashboardLayout>
</RootLayout>4.2 文件优先级规则
| 优先级 | 文件 | 是否必须 |
|---|---|---|
| 1 | layout.tsx | 根布局必须 |
| 2 | template.tsx | 可选 |
| 3 | error.tsx | 可选 |
| 4 | loading.tsx | 可选 |
| 5 | not-found.tsx | 可选 |
| 6 | page.tsx / route.ts | 至少有一个 |
4.3 Metadata 导出
每个 page.tsx 和 layout.tsx 都可以导出 metadata,实现 SEO:
// 静态 Metadata
export const metadata: Metadata = {
title: 'AI 对话',
description: '与 AI 进行多轮对话',
}
// 动态 Metadata(根据路由参数生成)
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { id } = await params
const chat = await db.query.chats.findFirst({ where: eq(chats.id, id) })
return {
title: chat?.title ?? 'AI 对话',
description: `对话记录:${chat?.title}`,
}
}5. 生产级目录结构实战
5.1 AI SaaS 完整目录
app/
├── (marketing)/ # 营销页
│ ├── layout.tsx # Header + Footer
│ ├── page.tsx # 首页 (/)
│ ├── pricing/page.tsx # 定价 (/pricing)
│ ├── blog/
│ │ ├── page.tsx # 博客列表 (/blog)
│ │ └── [slug]/page.tsx # 博客详情 (/blog/:slug)
│ └── changelog/page.tsx # 更新日志 (/changelog)
├── (auth)/ # 认证
│ ├── layout.tsx # 居中卡片
│ ├── login/page.tsx # /login
│ ├── register/page.tsx # /register
│ └── verify-email/page.tsx # /verify-email
├── (dashboard)/ # 仪表盘
│ ├── layout.tsx # Sidebar + Header
│ ├── chat/
│ │ ├── page.tsx # /chat
│ │ ├── [id]/page.tsx # /chat/:id
│ │ └── new/page.tsx # /chat/new
│ ├── knowledge/
│ │ ├── page.tsx # /knowledge
│ │ └── [id]/page.tsx # /knowledge/:id
│ ├── billing/page.tsx # /billing
│ └── settings/
│ ├── page.tsx # /settings
│ └── team/page.tsx # /settings/team
├── admin/ # 管理后台
│ ├── layout.tsx
│ ├── page.tsx # /admin
│ ├── users/page.tsx # /admin/users
│ └── analytics/page.tsx # /admin/analytics
├── api/ # API Routes
│ ├── chat/route.ts # POST /api/chat(AI 对话流式)
│ ├── webhook/
│ │ └── stripe/route.ts # POST /api/webhook/stripe
│ └── trpc/[trpc]/route.ts # tRPC 路由
├── layout.tsx # 根布局
├── not-found.tsx # 全局 404
└── globals.css # 全局样式
5.2 目录组织原则
- Route Groups 按用户角色分:marketing(访客)、auth(未登录)、dashboard(已登录)、admin(管理员)
- 每个 Route Group 有独立 layout:不同角色看到不同的壳
- api/ 放 Route Handlers:只处理非页面请求(Webhook、流式 AI、tRPC)
- 业务组件就近放:
app/(dashboard)/chat/_components/或components/chat/ - 共享 UI 放 components/ui/:shadcn/ui 组件统一管理
本章小结
- App Router 的核心约定:
page.tsx= 页面、layout.tsx= 布局、route.ts= API - 七种特殊文件各司其职:layout(持久壳)、template(重新挂载壳)、loading(加载态)、error(错误边界)、not-found(404)、default(并行路由回退)、page(页面内容)
- Route Groups
()影响布局不影响 URL,是大型应用组织代码的关键 - 渲染嵌套从外到内:layout → template → error → loading → page
- Metadata 支持静态和动态导出,实现精细化 SEO
下一章,我们将深入 TypeScript 在 Next.js 16 中的全量实践。