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.tsAPI 路由HTTP 请求(GET/POST 等)
layout.tsx布局包裹不直接访问,自动包裹子页面
page.tsx 和 route.ts 不能同级共存

app/chat/page.tsxapp/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.tsxtemplate.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 文件优先级规则

优先级文件是否必须
1layout.tsx根布局必须
2template.tsx可选
3error.tsx可选
4loading.tsx可选
5not-found.tsx可选
6page.tsx / route.ts至少有一个

4.3 Metadata 导出

每个 page.tsxlayout.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 目录组织原则

  1. Route Groups 按用户角色分:marketing(访客)、auth(未登录)、dashboard(已登录)、admin(管理员)
  2. 每个 Route Group 有独立 layout:不同角色看到不同的壳
  3. api/ 放 Route Handlers:只处理非页面请求(Webhook、流式 AI、tRPC)
  4. 业务组件就近放app/(dashboard)/chat/_components/components/chat/
  5. 共享 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 中的全量实践。