
为什么 App Router 改变了一切
当 Next.js 在版本 13 中引入 App Router 时,它不仅仅是一个新的文件系统约定——它是对 React 应用结构和渲染方式的根本性重新思考。两年后,随着 Next.js 15 稳定版发布,App Router 已成为默认和推荐的方法。
但思维模式的转变是真实的。如果你来自 Pages Router,Server Actions 会显得陌生,"use client" 指令似乎随意,新的数据获取方式打破了你所有的旧模式。
本指南将消除困惑。我们将涵盖 App Router 的实际工作原理、何时使用 Server 与 Client Components,以及可扩展的数据获取模式。

核心概念:React Server Components
App Router 建立在 React Server Components (RSC) 之上。理解 RSC 是理解 App Router 的前提。
Server Components 在服务器端渲染,从不向客户端发送 JavaScript。它们可以:
- 直接访问数据库、文件系统和 API
- 在组件级别使用 async/await
- 导入大型依赖而不影响客户端包大小
- 不能使用 hooks、浏览器 API 或事件处理程序
Client Components 是你熟悉的传统 React 组件。它们:
- 在浏览器中运行(也在 SSR 期间)
- 可以使用 useState、useEffect、事件处理程序
- 可以访问浏览器 API
- 需要在文件顶部添加 "use client" 指令
关键点:在 App Router 中,Server Components 是默认的。你选择的是客户端行为,而不是服务器行为。
文件系统约定
app/
├── layout.tsx # 根布局(始终是 Server Component)
├── page.tsx # 首页
├── loading.tsx # 加载 UI(Suspense 边界)
├── error.tsx # 错误边界(必须是 Client Component)
├── not-found.tsx # 404 页面
├── blog/
│ ├── layout.tsx # 嵌套布局
│ ├── page.tsx # /blog 路由
│ └── [slug]/
│ ├── page.tsx # /blog/[slug] 路由
│ └── loading.tsx # 动态文章的加载状态
└── api/
└── users/
└── route.ts # API 路由:GET/POST /api/users
特殊文件
| 文件 | 用途 |
|---|---|
page.tsx |
路由 UI — 使路由公开可访问 |
layout.tsx |
共享 UI — 在导航间持久存在(状态保留) |
loading.tsx |
使用 React Suspense 的即时加载 UI |
error.tsx |
路由段的错误边界 |
not-found.tsx |
由 notFound() 函数渲染 |
route.ts |
API 端点 — 不能与 page.tsx 共存 |
template.tsx |
类似布局,但在导航时重新挂载 |
middleware.ts |
在请求前运行(边缘运行时) |
数据获取:新方式
忘记 getServerSideProps 和 getStaticProps。在 App Router 中,数据获取直接在组件中进行:
// app/blog/[slug]/page.tsx
// 这是一个 Server Component — 默认异步
interface Props {
params: { slug: string }
}
async function getPost(slug: string) {
const res = await fetch(`https://api.example.com/posts/${slug}`, {
next: { revalidate: 3600 } // ISR:每小时重新验证
})
if (!res.ok) {
notFound() // 渲染 not-found.tsx
}
return res.json()
}
export default async function BlogPost({ params }: Props) {
const post = await getPost(params.slug) // 直接异步调用!
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
)
}
// 可选:在构建时生成静态参数
export async function generateStaticParams() {
const posts = await fetch('https://api.example.com/posts').then(r => r.json())
return posts.map((post: any) => ({
slug: post.slug,
}))
}
fetch() 缓存行为
Next.js 扩展了原生 fetch() API,增加了缓存选项:
// 无限缓存(静态 — 类似旧的 getStaticProps)
fetch(url, { cache: 'force-cache' }) // 默认行为
// 从不缓存(动态 — 类似旧的 getServerSideProps)
fetch(url, { cache: 'no-store' })
// ISR:N 秒后重新验证
fetch(url, { next: { revalidate: 60 } })
// 基于标签的重新验证
fetch(url, { next: { tags: ['posts', 'post-123'] } })
// 按标签重新验证(在 Server Action 或 Route Handler 中)
import { revalidateTag } from 'next/cache'
revalidateTag('posts') // 清除所有标记为 'posts' 的 fetch

数据库查询(无需 fetch)
import { db } from '@/lib/database'
export default async function UsersPage() {
// 直接数据库调用 — 无需 API!
const users = await db.query('SELECT * FROM users ORDER BY created_at DESC')
return (
<ul>
{users.rows.map(user => (
<li key={user.id}>{user.name} — {user.email}</li>
))}
</ul>
)
}
这就是 Server Components 的强大之处——你的数据库凭据永远不会离开服务器。
Server vs Client Components:决策框架
需要交互性(onClick、onChange)? → Client Component
需要浏览器 API(localStorage、window)? → Client Component
需要 React hooks(useState、useEffect)? → Client Component
需要基于类的动画? → Client Component
获取数据? → Server Component
访问后端资源? → Server Component
敏感数据(API 密钥)? → Server Component
大型依赖? → Server Component(保持包体积小)
SEO 关键内容? → Server Component
组合 Server 和 Client Components
// app/dashboard/page.tsx — Server Component
import UserStats from './UserStats' // Server Component
import InteractiveChart from './InteractiveChart' // Client Component
export default async function Dashboard() {
const stats = await fetchStats() // 服务器端数据获取
return (
<div>
<UserStats data={stats} />
{/* 将可序列化的数据作为 props 传递给 Client Component */}
<InteractiveChart initialData={stats.chartData} />
</div>
)
}
// app/dashboard/InteractiveChart.tsx
'use client' // 必须位于文件顶部
import { useState } from 'react'
import { Chart } from '@/components/Chart'
interface Props {
initialData: ChartData[]
}
export default function InteractiveChart({ initialData }: Props) {
const [filter, setFilter] = useState('all')
const filtered = filter === 'all'
? initialData
: initialData.filter(d => d.category === filter)
return (
<div>
<select onChange={e => setFilter(e.target.value)}>
<option value="all">全部</option>
<option value="revenue">收入</option>
<option value="users">用户</option>
</select>
<Chart data={filtered} />
</div>
)
}
重要:你不能将 Server Component 导入 Client Component。但你可以将 Server Components 作为 children/props 传递:
// 这种模式有效 — Server Component 作为 children prop
'use client'
export default function ClientWrapper({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useState(false)
return (
<div>
<button onClick={() => setOpen(!open)}>切换</button>
{open && children} {/* children 可以是 Server Component */}
</div>
)
}
// 在 Server Component 中使用:
<ClientWrapper>
<ServerComponent /> {/* 这是有效的! */}
</ClientWrapper>
布局和嵌套路由
布局包裹其子组件并在导航间保持状态(不重新挂载):
// app/layout.tsx — 根布局(必需)
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body>
<nav>/* 导航 */</nav>
<main>{children}</main>
<footer>/* 页脚 */</footer>
</body>
</html>
)
}
// app/dashboard/layout.tsx — 嵌套布局
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="dashboard">
<aside>/* 侧边栏 */</aside>
<section>{children}</section>
</div>
)
}
并行路由
在同一布局中同时渲染多个页面:
app/
└── dashboard/
├── layout.tsx
├── page.tsx
├── @analytics/ # 并行路由插槽
│ └── page.tsx
└── @team/ # 并行路由插槽
└── page.tsx
// app/dashboard/layout.tsx
export default function Layout({
children,
analytics,
team,
}: {
children: React.ReactNode
analytics: React.ReactNode
team: React.ReactNode
}) {
return (
<div>
{children}
<div className="widgets">
{analytics}
{team}
</div>
</div>
)
}

Server Actions:无需 API 路由的表单
Server Actions 允许你直接从表单提交或事件处理程序运行服务器端代码:
// app/contact/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
const ContactSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(10),
})
export async function submitContact(formData: FormData) {
const parsed = ContactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
})
if (!parsed.success) {
return { error: '无效的表单数据', issues: parsed.error.issues }
}
// 直接数据库插入 — 无需 API
await db.query(
'INSERT INTO contacts (name, email, message) VALUES ($1, $2, $3)',
[parsed.data.name, parsed.data.email, parsed.data.message]
)
revalidatePath('/admin/contacts')
return { success: true }
}
// app/contact/page.tsx — 使用 Server Action 的 Server Component
import { submitContact } from './actions'
export default function ContactPage() {
return (
<form action={submitContact}> {/* 直接引用 Server Action */}
<input name="name" type="text" required />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit">发送</button>
</form>
)
}
使用 useActionState 进行渐进增强:
'use client'
import { useActionState } from 'react'
import { submitContact } from './actions'
export default function ContactForm() {
const [state, action, pending] = useActionState(submitContact, null)
return (
<form action={action}>
{state?.error && <p className="error">{state.error}</p>}
{state?.success && <p className="success">消息已发送!</p>}
<input name="name" type="text" required />
<input name="email" type="email" required />
<textarea name="message" required />
<button type="submit" disabled={pending}>
{pending ? '发送中...' : '发送'}
</button>
</form>
)
}
元数据和 SEO
// 静态元数据
export const metadata = {
title: '博客',
description: '阅读我们最新的文章',
}
// 动态元数据
export async function generateMetadata({ params }: Props) {
const post = await getPost(params.slug)
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [{ url: post.coverImage }],
},
twitter: {
card: 'summary_large_image',
title: post.title,
},
}
}
从 Pages Router 迁移
| Pages Router | App Router |
|---|---|
pages/index.tsx |
app/page.tsx |
pages/_app.tsx |
app/layout.tsx |
pages/_document.tsx |
app/layout.tsx (HTML shell) |
getStaticProps |
async Server Component |
getServerSideProps |
async Server Component + cache: 'no-store' |
getStaticPaths |
generateStaticParams |
pages/api/route.ts |
app/api/route/route.ts |
路由处理程序(API 路由)
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const page = Number(searchParams.get('page') ?? 1)
const users = await db.getUsers({ page, limit: 20 })
return NextResponse.json({ users, page })
}
export async function POST(request: NextRequest) {
const body = await request.json()
const user = await db.createUser(body)
return NextResponse.json(user, { status: 201 })
}
性能:流式传输和 Suspense
// app/dashboard/page.tsx
import { Suspense } from 'react'
import UserProfile from './UserProfile'
import RecentOrders from './RecentOrders'
import Analytics from './Analytics'
export default function Dashboard() {
return (
<div>
{/* 快速 — 立即渲染 */}
<h1>仪表盘</h1>
{/* 每个 Suspense 边界独立流式传输 */}
<Suspense fallback={<ProfileSkeleton />}>
<UserProfile /> {/* 异步 Server Component */}
</Suspense>
<Suspense fallback={<OrdersSkeleton />}>
<RecentOrders /> {/* 准备就绪时流式传输 */}
</Suspense>
<Suspense fallback={<AnalyticsSkeleton />}>
<Analytics /> {/* 不阻塞其他内容 */}
</Suspense>
</div>
)
}
这种流式传输方法意味着用户逐步看到内容——快速部分立即出现,而较慢的数据获取在后台完成。
常见陷阱
1. "use client" 传播:在文件中添加 "use client" 会使其所有导入也成为 Client Components。将 Client Components 保持在树的叶子节点。
2. 序列化:从 Server 传递到 Client Components 的数据必须是可序列化的(不能有函数,Date 会变成字符串等)。
3. Server Components 中的 Context:React context 在 Server Components 中不起作用。在 Client Component 包装器中使用它。
4. cookies() 和 headers():这些会使路由变为动态。仅在需要时调用它们。
// 这会使整个路由变为动态:
import { cookies } from 'next/headers'
const token = cookies().get('auth-token')
// 更好:从布局或中间件作为 prop 传递
5. Server Component 中的 Client Component:你不能在 Client Components 中导入仅服务器代码(如数据库客户端)——它会泄漏到浏览器包中。
App Router 有陡峭的学习曲线,但一旦 Server/Client Component 思维模式建立起来,构建 Next.js 应用程序将变得非常强大。仅消除大多数数据操作的 API 路由就显著简化了全栈开发。
→ 使用 URL 解析器 解析和分析 URL。