Next.js 15 带来了 App Router 架构的全面成熟。与传统的 Pages Router 相比,App Router 基于 React Server Components (RSC) 构建,从根本上改变了数据获取、渲染和缓存的方式。本文将从架构原理出发,结合大量实战代码,带你深入理解 Server Components、流式渲染、缓存策略等核心机制,帮助你在生产项目中做出正确的技术决策。
无论你是从 Pages Router 迁移,还是从零开始构建新项目,理解 App Router 的底层模型都至关重要。它不仅仅是一次路由方案的升级,更是 React 全栈愿景在框架层的落地。

一、App Router 核心架构变革
App Router 的目录结构基于文件系统路由,但引入了嵌套布局(Nested Layout)的概念。每个目录下的
1 | layout.tsx |
和
1 | page.tsx |
共同构成渲染树。与 Pages Router 最大的区别在于:默认情况下,所有组件都是 Server Components,只有显式声明
1 | "use client" |
的才是 Client Components。
1.1 目录结构与路由约定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19 app/
├── layout.tsx # 根布局(必须)
├── page.tsx # 首页 /
├── globals.css # 全局样式
├── about/
│ └── page.tsx # /about
├── blog/
│ ├── layout.tsx # blog 专属布局
│ ├── page.tsx # /blog
│ └── [slug]/
│ ├── page.tsx # /blog/:slug
│ └── loading.tsx # 加载 UI
├── dashboard/
│ ├── layout.tsx
│ ├── page.tsx
│ └── settings/
│ └── page.tsx # /dashboard/settings
└── api/
└── route.ts # /api 路由处理器
关键约定文件包括:
-
1layout.tsx
:布局组件,在子路由切换时保持不重新渲染
-
1page.tsx
:路由唯一入口,定义该路径渲染的内容
-
1loading.tsx
:基于 React Suspense 的加载占位 UI
-
1error.tsx
:错误边界,捕获子组件的运行时错误
-
1not-found.tsx
:404 页面
-
1route.ts
:API 路由处理器(替代 Pages Router 的
1pages/api)
1.2 Server Components 与 Client Components 的边界
Server Components 在服务端执行,无法使用
1 | useState |
、
1 | useEffect |
等浏览器 API,但可以直接访问数据库、文件系统,且不会增加客户端 bundle 体积。Client Components 则在客户端水合(hydrate),可以使用所有 React Hooks。
一个常见的误区是把整个应用都标记为
1 | "use client" |
。这会让你的项目退化回 Pages Router 的模式,失去 RSC 带来的性能优势。正确的做法是在组件树的叶子节点按需标记。
二、Server Components 实战与数据获取
在 App Router 中,数据获取直接在 Server Components 中以
1 | async/await |
方式进行,不再需要
1 | getServerSideProps |
或
1 | getStaticProps |
。这种基于 fetch 的数据获取方式更加直观,也更接近 React Suspense 的设计理念。
2.1 直接数据库查询
Server Components 可以直接导入并执行数据库查询,无需经过 API 层。这大幅简化了架构:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 // app/blog/page.tsx
import { db } from '@/lib/db'
import { PostCard } from '@/components/PostCard'
export default async function BlogPage() {
// 直接在服务端查询数据库
const posts = await db.post.findMany({
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 20,
include: { author: true },
})
return (
<main className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">最新文章</h1>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</main>
)
}
2.2 Client Components 与 Server Components 协作
当需要交互能力时,将交互部分提取为 Client Component,通过 props 传递数据。注意:从 Server Component 传给 Client Component 的 props 必须是可序列化的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 // components/LikeButton.tsx
"use client"
import { useState } from 'react'
export function LikeButton({ initialCount, postId }: {
initialCount: number
postId: string
}) {
const [count, setCount] = useState(initialCount)
const [loading, setLoading] = useState(false)
async function handleLike() {
setLoading(true)
try {
const res = await fetch(`/api/posts/${postId}/like`, { method: 'POST' })
const data = await res.json()
setCount(data.count)
} finally {
setLoading(false)
}
}
return (
<button onClick={handleLike} disabled={loading}>
❤ {count}
</button>
)
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17 // app/blog/[slug]/page.tsx
import { db } from '@/lib/db'
import { LikeButton } from '@/components/LikeButton'
export default async function PostPage({ params }: { params: { slug: string } }) {
const post = await db.post.findUnique({ where: { slug: params.slug } })
if (!post) return <p>文章不存在</p>
return (
<article>
<h1>{post.title}</h1>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
<LikeButton initialCount={post.likeCount} postId={post.id} />
</article>
)
}
2.3 Server Actions:无需 API 的表单处理
Next.js 15 深度支持 React Server Actions。你可以直接在 Server Component 中定义处理函数,无需手写 API 路由:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25 // app/posts/create/page.tsx
import { revalidatePath } from 'next/cache'
async function createPost(formData: FormData) {
"use server"
const title = formData.get('title') as string
const content = formData.get('content') as string
await db.post.create({
data: { title, content, authorId: getCurrentUserId() },
})
revalidatePath('/blog') // 重新验证缓存
}
export default function CreatePostPage() {
return (
<form action={createPost} className="space-y-4">
<input name="title" placeholder="标题" required />
<textarea name="content" placeholder="正文" required />
<button type="submit">发布</button>
</form>
)
}
三、流式渲染与 Suspense 实战
流式渲染(Streaming SSR)是 App Router 的标志性能力。传统 SSR 必须等待所有数据获取完成后才能返回完整 HTML,而流式渲染允许服务端先发送页面骨架,再逐步将数据就绪的部分流式推送。这极大改善了首屏可交互时间(TTFB)。

3.1 使用 loading.tsx 自动生成 Suspense 边界
在目录中添加
1 | loading.tsx |
后,Next.js 会自动将其包裹在 Suspense 边界中。当
1 | page.tsx |
中的异步数据获取挂起时,先展示 loading UI:
1
2
3
4
5
6
7
8
9
10
11
12
13
14 // app/blog/loading.tsx
export default function Loading() {
return (
<div className="grid gap-6 md:grid-cols-3">
{[...Array(6)].map((_, i) => (
<div key={i} className="animate-pulse rounded-lg border p-6">
<div className="h-4 w-3/4 bg-gray-200 rounded mb-4" />
<div className="h-3 w-full bg-gray-200 rounded mb-2" />
<div className="h-3 w-1/2 bg-gray-200 rounded" />
</div>
))}
</div>
)
}
3.2 细粒度 Suspense:按组件流式传输
更高级的用法是在组件级别使用 Suspense,让页面的不同部分独立流式渲染。先加载的内容立即展示,慢的部分显示骨架屏:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 // app/dashboard/page.tsx
import { Suspense } from 'react'
import { RevenueChart, LatestOrders, UserStats } from '@/components/dashboard'
export default function DashboardPage() {
return (
<div className="space-y-8 p-8">
<h1 className="text-2xl font-bold">控制台</h1>
{/* 快速数据 - 立即渲染 */}
<UserStats />
{/* 慢查询 - 流式渲染 */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
{/* 另一个慢查询 - 独立流式渲染 */}
<Suspense fallback={<OrdersSkeleton />}>
<LatestOrders />
</Suspense>
</div>
)
}
渲染顺序示意图:
| 阶段 | 客户端看到的内容 | 服务端状态 |
|---|---|---|
| T0 | 页面骨架 + UserStats | HTML 开始流式发送 |
| T1(约 500ms) | RevenueChart 就绪 | 该块数据查询完成 |
| T2(约 1.2s) | LatestOrders 就绪 | 整页渲染完成 |
关键收益:用户不需要等待最慢的查询就能看到页面框架和部分内容,体验大幅提升。
四、数据缓存策略深度解析
Next.js 15 的缓存系统是理解 App Router 的关键难点。Next.js 提供了四层独立的缓存机制,每层服务于不同场景。理解它们的关系,才能做出正确的缓存决策。
4.1 四层缓存体系
| 缓存层 | 作用范围 | 失效方式 | 默认行为 |
|---|---|---|---|
| Request Memoization | 单次请求内 | 请求结束自动清除 | 自动开启 |
| Data Cache | 跨请求、跨部署 | revalidate 时间或手动 | fetch 默认缓存 |
| Full Route Cache | 整个路由的 HTML | revalidatePath | 静态路由自动缓存 |
| Router Cache | 客户端导航缓存 | 5 分钟或刷新 | 浏览器内存 |
4.2 fetch 缓存控制详解
Next.js 扩展了原生
1 | fetch |
,增加了
1 | next.revalidate |
和
1 | next.tags |
选项:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 // ISR:每 60 秒重新验证
const posts = await fetch('https://api.example.com/posts', {
next: { revalidate: 60 },
}).then(res => res.json())
// 按需验证:通过 tag 标记
const post = await fetch(`https://api.example.com/posts/${id}`, {
next: { tags: ['posts'] },
}).then(res => res.json())
// 完全禁用缓存(每次请求都获取最新数据)
const data = await fetch('https://api.example.com/realtime', {
cache: 'no-store',
}).then(res => res.json())
// 在 Server Action 中按需失效
import { revalidateTag } from 'next/cache'
async function updatePost(formData: FormData) {
"use server"
await db.post.update({ where: { id: formData.get('id') }, data: {...} })
revalidateTag('posts') // 失效所有标记为 'posts' 的缓存
revalidatePath('/blog') // 失效 /blog 路由缓存
}
缓存策略选择决策表:
- 静态内容(博客文章、产品页):使用
1revalidate
时间缓存 +
1revalidateTag按需更新
- 准实时数据(排行榜、统计):短
1revalidate
(10-30 秒)
- 用户私有数据(个人信息):使用
1cache: 'no-store'
或动态渲染
- 极少变更(配置、字典):长
1revalidate
(24 小时)
五、布局嵌套与错误边界
App Router 的嵌套布局是它最强大的特性之一。父布局在子路由切换时不会重新渲染,这意味着导航栏、侧边栏、全局样式只加载一次。但这也带来了状态管理的注意事项。
5.1 嵌套布局渲染机制
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35 // app/layout.tsx — 根布局
import { Inter } from 'next/font/google'
import { Navbar } from '@/components/Navbar'
import { Footer } from '@/components/Footer'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: { default: '我的应用', template: '%s | 我的应用' },
description: '基于 Next.js 15 构建',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN" className={inter.className}>
<body>
<Navbar />
<div className="min-h-screen">{children}</div>
<Footer />
</body>
</html>
)
}
// app/dashboard/layout.tsx — dashboard 专属布局
import { Sidebar } from '@/components/Sidebar'
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex">
<Sidebar />
<main className="flex-1 p-8">{children}</main>
</div>
)
}
渲染层级为:RootLayout → DashboardLayout → Page。切换
1 | /dashboard/settings |
到
1 | /dashboard/analytics |
时,RootLayout 和 DashboardLayout 都不会重新渲染,只有 Page 部分更新。
5.2 错误边界与恢复
1 | error.tsx |
必须是 Client Component,因为需要捕获运行时错误并提供重试按钮:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29 // app/blog/[slug]/error.tsx
"use client"
import { useEffect } from 'react'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('页面错误:', error)
}, [error])
return (
<div className="flex flex-col items-center justify-center py-20">
<h2 className="text-xl font-semibold mb-2">出错了</h2>
<p className="text-gray-500 mb-6">{error.message || '加载内容时发生错误'}</p>
<button
onClick={reset}
className="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"
>
重试
</button>
</div>
)
}
六、生产部署与性能优化
将 App Router 应用部署到生产环境,需要在构建配置、性能监控、SEO 优化等方面做好充分准备。
6.1 动态与静态路由自动判断
Next.js 构建时会自动分析每个路由的数据依赖,判断是静态(Static)还是动态(Dynamic)。没有使用动态 API(如
1 | cookies() |
、
1 | headers() |
)且 fetch 使用了缓存的路由会被静态预渲染:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31 // next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
// 实验性配置
experimental: {
// 开启 React Compiler 优化(React 19+)
reactCompiler: true,
},
// 图片优化配置
images: {
formats: ['image/avif', 'image/webp'],
remotePatterns: [
{ protocol: 'https', hostname: 'images.unsplash.com' },
],
},
// 开启 bundle 分析
webpack: (config, { isServer }) => {
if (!isServer) {
config.optimization.splitChunks = {
chunks: 'all',
cacheGroups: {
vendor: { test: /[\\/]node_modules[\\/]/, name: 'vendors' },
},
}
}
return config
},
}
export default nextConfig
6.2 Metadata API 与 SEO
App Router 提供了基于文件的 Metadata API,替代 Pages Router 的
1 | next-seo |
等第三方库:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35 // app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { db } from '@/lib/db'
export async function generateMetadata({
params,
}: {
params: { slug: string }
}): Promise<Metadata> {
const post = await db.post.findUnique({ where: { slug: params.slug } })
if (!post) return { title: '文章不存在' }
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.createdAt.toISOString(),
images: [{ url: post.coverImage, width: 1200, height: 630 }],
},
twitter: {
card: 'summary_large_image',
title: post.title,
},
}
}
// 静态参数生成
export async function generateStaticParams() {
const posts = await db.post.findMany({ where: { published: true } })
return posts.map((post) => ({ slug: post.slug }))
}
6.3 中间件实现鉴权
Next.js 15 的中间件运行在边缘运行时,适合做认证检查、重定向、A/B 测试等轻量逻辑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24 // middleware.ts(位于项目根目录)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const protectedPaths = ['/dashboard', '/admin', '/settings']
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')?.value
const { pathname } = request.nextUrl
const isProtected = protectedPaths.some(p => pathname.startsWith(p))
if (isProtected && !token) {
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
}
七、迁移建议与常见陷阱
从 Pages Router 迁移到 App Router 是一项需要谨慎规划的工作。以下是一些实战经验和常见陷阱。
7.1 渐进式迁移策略
Next.js 支持 Pages Router 和 App Router 并存,推荐采用渐进式迁移:
- 第一步:保持
1pages/
目录不变,新建
1app/目录
- 第二步:将简单的静态页面(关于、联系)迁移到 App Router
- 第三步:迁移布局和共享组件到
1app/layout.tsx
- 第四步:逐个迁移功能页面,验证数据获取和缓存行为
- 第五步:迁移 API 路由到
1app/api/route.ts
- 第六步:删除
1pages/
目录,完成迁移
7.2 常见陷阱清单
| 陷阱 | 原因 | 解决方案 | ||||
|---|---|---|---|---|---|---|
| 客户端组件中导入服务端模块报错 | Server-only 代码不能进入客户端 bundle | 使用
包标记,或拆分逻辑 |
||||
| 页面全部变为动态渲染 | 使用了
等动态函数 |
将动态部分隔离到子组件 | ||||
| 布局状态在导航时丢失 | App Router 布局不重新挂载 | 状态应放在 layout 或通过 URL 同步 | ||||
| fetch 数据不更新 | Data Cache 默认缓存 | 设置
或
|
||||
后无法 async |
Client Components 不支持 async | 在父 Server Component 中获取数据后传入 |
Next.js 15 App Router 代表了 React 全栈开发的未来方向。Server Components 简化了数据获取、流式渲染改善了用户体验、四层缓存体系提供了精细的性能控制。虽然学习曲线相比 Pages Router 更陡,但一旦掌握这些核心概念,你将能构建出更快、更简洁、更易维护的 Web 应用。建议在一个新项目中实践本文的代码示例,逐步体会 RSC 架构带来的开发体验变革。
汤不热吧