2025年Next.js 15 App Router实战指南:从零构建现代化Web应用
深入探讨Next.js 15 App Router的实战应用,包括路由架构、服务器组件、数据获取、布局系统、中间件、元数据优化等核心技术,结合实际案例从零构建完整的现代化Web应用。
Next.js 15 带来了许多令人兴奋的新特性,特别是 App Router 的成熟和稳定。相比传统的 Pages Router,App Router 提供了更强大的路由控制、更好的性能优化和更直观的文件组织方式。
本文将带您从零开始,使用 Next.js 15 App Router 构建一个完整的现代化 Web 应用,深入探讨每个核心概念和实战技巧。
一、App Router 核心概念#
1.1 App Router vs Pages Router#
| 特性 | Pages Router (传统) | App Router (新) |
|---|
| 文件位置 | pages/ 目录 | app/ 目录 |
| 路由方式 | 文件系统路由 | 文件系统路由 |
| 数据获取 | getServerSideProps | 服务器组件 + fetch |
| 布局 | 需要 _app.js | 内置 Layout 系统 |
| 加载状态 | 手动实现 | 自动 loading.js |
| 错误处理 | _error.js | error.js |
| 流式渲染 | 不支持 | 支持 |
| 服务器组件 | 不支持 | 原生支持 |
1.2 文件组织结构#
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
| my-app/
├── app/
│ ├── (marketing)/ # 路由组 - 不影响URL
│ │ ├── about/
│ │ │ └── page.js # /about
│ │ ├── layout.js # 共享布局
│ │ └── page.js # / (首页)
│ ├── (dashboard)/ # 路由组
│ │ ├── dashboard/
│ │ │ ├── layout.js # Dashboard布局
│ │ │ ├── page.js # /dashboard
│ │ │ └── settings/
│ │ │ └── page.js # /dashboard/settings
│ │ └── layout.js # 认证布局
│ ├── api/ # API路由
│ │ └── users/
│ │ └── route.js # /api/users
│ ├── layout.js # 根布局
│ ├── page.js # 根页面
│ ├── loading.js # 全局加载状态
│ ├── error.js # 全局错误处理
│ ├── not-found.js # 404页面
│ └── globals.css # 全局样式
├── public/ # 静态资源
├── components/ # 共享组件
├── lib/ # 工具函数
└── next.config.js # Next.js配置
|
二、路由系统实战#
2.1 动态路由#
基础动态路由#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // app/blog/[slug]/page.tsx
interface BlogPostProps {
params: {
slug: string
}
}
export default async function BlogPost({ params }: BlogPostProps) {
const { slug } = params
// 在服务器端获取数据
const post = await fetch(`https://api.example.com/posts/${slug}`)
.then(res => res.json())
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
|
捕获所有路由#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // app/docs/[...slug]/page.tsx
// 匹配 /docs/a, /docs/a/b, /docs/a/b/c 等
export default async function DocPage({ params }: {
params: { slug: string[] }
}) {
const path = params.slug.join('/')
const content = await fetch(`https://api.example.com/docs/${path}`)
.then(res => res.json())
return (
<div>
<h1>文档: {path}</h1>
<div>{content.body}</div>
</div>
)
}
|
2.2 路由组(Route Groups)#
路由组允许您组织文件结构而不影响 URL 路径:
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
| // app/(marketing)/about/page.tsx -> /about
// app/(marketing)/contact/page.tsx -> /contact
// app/(dashboard)/profile/page.tsx -> /profile
// app/(marketing)/layout.tsx
export default function MarketingLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div>
<MarketingHeader />
<main>{children}</main>
<MarketingFooter />
</div>
)
}
// app/(dashboard)/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="dashboard">
<Sidebar />
<main>{children}</main>
</div>
)
}
|
2.3 平行路由(Parallel Routes)#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // app/dashboard/@dashboard/layout.tsx
export default function DashboardLayout({
children, // 主内容
analytics, // /dashboard/@analytics
notifications, // /dashboard/@notifications
}: {
children: React.ReactNode
analytics: React.ReactNode
notifications: React.ReactNode
}) {
return (
<div className="grid grid-cols-[1fr_300px]">
<div className="main">
{children}
</div>
<aside className="sidebar">
{analytics}
{notifications}
</aside>
</div>
)
}
|
三、服务器组件(Server Components)#
3.1 理解服务器组件#
服务器组件(RSC)在服务器上渲染,不发送 JavaScript 到客户端:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // app/components/ProductList.tsx (服务器组件)
// ✅ 可以直接访问数据库、文件系统、后端API
// ❌ 不能使用 useState、useEffect、事件处理器
import { db } from '@/lib/db'
export default async function ProductList() {
// 直接查询数据库
const products = await db.product.findMany()
return (
<div>
{products.map(product => (
<div key={product.id}>
<h3>{product.name}</h3>
<p>{product.price}</p>
</div>
))}
</div>
)
}
|
3.2 客户端组件#
客户端组件需要 'use client' 指令:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| // app/components/ProductCard.tsx (客户端组件)
'use client'
import { useState } from 'react'
export function ProductCard({ product }: { product: Product }) {
const [liked, setLiked] = useState(false)
return (
<div>
<h3>{product.name}</h3>
<button onClick={() => setLiked(!liked)}>
{liked ? '❤️' : '🤍'}
</button>
</div>
)
}
|
3.3 组合使用#
最佳实践: 尽可能使用服务器组件,只在需要交互时使用客户端组件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // app/products/page.tsx
import { ProductCard } from '@/components/ProductCard'
import { db } from '@/lib/db'
// 服务器组件
export default async function ProductsPage() {
const products = await db.product.findMany()
return (
<div>
<h1>产品列表</h1>
<div className="grid">
{products.map(product => (
// 只在交互部分使用客户端组件
<ProductCard key={product.id} product={product} />
))}
</div>
</div>
)
}
|
四、数据获取与缓存#
4.1 Server Actions#
Server Actions 允许从客户端直接调用服务器函数:
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/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
export async function createProduct(formData: FormData) {
const name = formData.get('name') as string
const price = parseFloat(formData.get('price') as string)
const product = await db.product.create({
data: { name, price }
})
// 重新验证缓存
revalidatePath('/products')
return product
}
export async function deleteProduct(id: string) {
await db.product.delete({ where: { id } })
revalidatePath('/products')
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // app/products/page.tsx
import { createProduct, deleteProduct } from '@/actions'
export default function ProductsPage() {
return (
<div>
<form action={createProduct}>
<input name="name" placeholder="产品名称" />
<input name="price" type="number" placeholder="价格" />
<button type="submit">添加</button>
</form>
<ProductList onDelete={deleteProduct} />
</div>
)
}
|
4.2 数据缓存策略#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // app/products/page.tsx
import { unstable_cache } from 'next/cache'
// 缓存数据获取
const getProducts = unstable_cache(
async () => {
return await db.product.findMany()
},
['products'], // 缓存键
{
revalidate: 3600, // 1小时
tags: ['products'] // 标签用于手动重新验证
}
)
export default async function ProductsPage() {
const products = await getProducts()
return <ProductList products={products} />
}
|
4.3 流式渲染(Streaming)#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| // app/dashboard/page.tsx
import { Suspense } from 'react'
export default function DashboardPage() {
return (
<div>
<h1>仪表板</h1>
{/* 独立加载每个部分 */}
<Suspense fallback={<StatsSkeleton />}>
<Stats />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart />
</Suspense>
<Suspense fallback={<RecentActivitySkeleton />}>
<RecentActivity />
</Suspense>
</div>
)
}
|
五、布局系统#
5.1 嵌套布局#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // app/layout.tsx (根布局)
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN">
<body>
<Header />
{children}
<Footer />
</body>
</html>
)
}
|
1
2
3
4
5
6
7
8
9
10
11
12
13
| // app/dashboard/layout.tsx
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="dashboard-layout">
<DashboardNav />
<main>{children}</main>
</div>
)
}
|
5.2 路由特定布局#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // app/(auth)/login/layout.tsx
export default function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="max-w-md w-full">
{children}
</div>
</div>
)
}
|
5.3 模板(Templates)#
模板在每次导航时重新渲染,不同于布局:
1
2
3
4
5
6
7
8
9
10
| // app/template.tsx
export default function Template({ children }: { children: React.ReactNode }) {
return (
<div>
<div className="transition-opacity">
{children}
</div>
</div>
)
}
|
六、加载与错误状态#
6.1 加载状态(Loading UI)#
1
2
3
4
5
6
7
8
9
10
11
12
13
| // app/products/loading.tsx
export default function ProductsLoading() {
return (
<div className="space-y-4">
{[...Array(6)].map((_, i) => (
<div key={i} className="animate-pulse">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/2 mt-2"></div>
</div>
))}
</div>
)
}
|
6.2 错误处理(Error UI)#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| // app/products/error.tsx
'use client' // 错误组件必须是客户端组件
export default function ProductsError({
error,
reset,
}: {
error: Error
reset: () => void
}) {
return (
<div className="error-container">
<h2>出错了!</h2>
<p>{error.message}</p>
<button onClick={reset}>重试</button>
</div>
)
}
|
6.3 全局错误处理#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // app/global-error.tsx
'use client'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<html>
<body>
<h2>发生严重错误!</h2>
<button onClick={reset}>重试</button>
</body>
</html>
)
}
|
七、元数据优化#
7.1 静态元数据#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: '我的应用',
description: '使用 Next.js 15 构建',
keywords: ['Next.js', 'React', 'Web'],
authors: [{ name: '作者名' }],
openGraph: {
title: '我的应用',
description: '使用 Next.js 15 构建',
images: ['/og-image.png'],
},
}
|
7.2 动态元数据#
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/blog/[slug]/page.tsx
import type { Metadata } from 'next'
interface Props {
params: { slug: string }
}
// generateMetadata 函数
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await fetch(`https://api.example.com/posts/${params.slug}`)
.then(res => res.json())
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
images: [post.coverImage],
},
}
}
export default async function BlogPost({ params }: Props) {
// ...
}
|
7.3 元数据模板#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // app/layout.tsx
export const metadata: Metadata = {
title: {
default: '默认标题',
template: '%s | 我的应用' // 自动应用
},
description: '默认描述',
openGraph: {
title: '默认OG标题',
description: '默认OG描述',
siteName: '我的应用',
},
}
// 使用
export async function generateMetadata(): Promise<Metadata> {
return {
title: '关于我们', // 自动变成 "关于我们 | 我的应用"
}
}
|
八、中间件(Middleware)#
8.1 身份验证#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
// 获取token
const token = request.cookies.get('auth-token')?.value
// 未登录重定向到登录页
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/dashboard/:path*', '/profile/:path*']
}
|
8.2 国际化#
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
export function middleware(request: NextRequest) {
const pathname = request.nextUrl.pathname
// 检查路径是否已包含语言
const pathnameIsMissingLocale = ['/', '/zh', '/en'].every(
locale => !pathname.startsWith(locale)
)
// 如果没有语言前缀,重定向
if (pathnameIsMissingLocale) {
const locale = request.headers.get('accept-language')?.includes('zh')
? 'zh'
: 'en'
return NextResponse.redirect(
new URL(`/${locale}${pathname}`, request.url)
)
}
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)']
}
|
8.3 A/B 测试#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| // middleware.ts
export function middleware(request: NextRequest) {
// 获取或设置用户变体
let variant = request.cookies.get('ab-test-variant')?.value
if (!variant) {
variant = Math.random() > 0.5 ? 'A' : 'B'
const response = NextResponse.rewrite(request.nextUrl)
response.cookies.set('ab-test-variant', variant)
return response
}
// 重写到变体页面
if (variant === 'B') {
const url = request.nextUrl.clone()
url.pathname = `/b${url.pathname}`
return NextResponse.rewrite(url)
}
}
|
九、实战案例:博客系统#
9.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
| blog-app/
├── app/
│ ├── (main)/
│ │ ├── page.tsx # 首页
│ │ ├── blog/
│ │ │ ├── page.tsx # 博客列表
│ │ │ ├── [slug]/
│ │ │ │ ├── page.tsx # 博客详情
│ │ │ │ └── edit/
│ │ │ │ └── page.tsx # 编辑页面
│ │ │ └── new/
│ │ │ └── page.tsx # 新建博客
│ │ ├── layout.tsx # 主布局
│ │ └── loading.tsx
│ ├── (auth)/
│ │ ├── login/
│ │ │ └── page.tsx
│ │ ├── register/
│ │ │ └── page.tsx
│ │ └── layout.tsx
│ ├── api/
│ │ └── blog/
│ │ └── route.ts
│ ├── layout.tsx
│ └── globals.css
├── components/
│ ├── BlogCard.tsx
│ ├── MarkdownRenderer.tsx
│ └── CommentSection.tsx
├── lib/
│ ├── db.ts
│ ├── auth.ts
│ └── utils.ts
└── types/
└── blog.ts
|
9.2 博客列表页#
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
| // app/(main)/blog/page.tsx
import Link from 'next/link'
import { db } from '@/lib/db'
export const revalidate = 3600 // ISR: 每小时重新验证
export default async function BlogPage({
searchParams,
}: {
searchParams: { page?: string }
}) {
const page = parseInt(searchParams.page || '1')
const limit = 10
const skip = (page - 1) * limit
const [posts, total] = await Promise.all([
db.post.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
author: { select: { name: true, avatar: true } }
}
}),
db.post.count()
])
const totalPages = Math.ceil(total / limit)
return (
<div>
<h1>博客</h1>
<div className="grid gap-6">
{posts.map(post => (
<Link
key={post.id}
href={`/blog/${post.slug}`}
className="block p-6 border rounded-lg hover:shadow-lg transition"
>
<h2>{post.title}</h2>
<p className="text-gray-600 mt-2">{post.excerpt}</p>
<div className="flex items-center gap-2 mt-4">
<img
src={post.author.avatar}
alt={post.author.name}
className="w-8 h-8 rounded-full"
/>
<span>{post.author.name}</span>
<span>·</span>
<time>
{new Date(post.createdAt).toLocaleDateString('zh-CN')}
</time>
</div>
</Link>
))}
</div>
<Pagination
currentPage={page}
totalPages={totalPages}
baseUrl="/blog"
/>
</div>
)
}
|
9.3 博客详情页#
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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
| // app/(main)/blog/[slug]/page.tsx
import { notFound } from 'next/navigation'
import { db } from '@/lib/db'
import { MarkdownRenderer } from '@/components/MarkdownRenderer'
import { CommentSection } from '@/components/CommentSection'
export async function generateMetadata({ params }: {
params: { slug: string }
}) {
const post = await db.post.findUnique({
where: { slug: params.slug }
})
if (!post) return {}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.coverImage || '/default-og.png'],
type: 'article',
publishedTime: post.createdAt.toISOString(),
authors: [post.author.name]
}
}
}
export default async function BlogPostPage({ params }: {
params: { slug: string }
}) {
const post = await db.post.findUnique({
where: { slug: params.slug },
include: {
author: { select: { name: true, avatar: true, bio: true } },
comments: {
include: { author: { select: { name: true, avatar: true } } }
}
}
})
if (!post) {
notFound()
}
return (
<article className="max-w-4xl mx-auto">
<header className="mb-8">
<h1 className="text-4xl font-bold">{post.title}</h1>
<div className="flex items-center gap-4 mt-4">
<img
src={post.author.avatar}
alt={post.author.name}
className="w-12 h-12 rounded-full"
/>
<div>
<div className="font-medium">{post.author.name}</div>
<div className="text-sm text-gray-600">
{new Date(post.createdAt).toLocaleDateString('zh-CN')}
</div>
</div>
</div>
</header>
<div className="prose prose-lg max-w-none">
<MarkdownRenderer content={post.content} />
</div>
<footer className="mt-12 pt-8 border-t">
<div className="flex gap-4">
{post.tags.map(tag => (
<span
key={tag}
className="px-3 py-1 bg-gray-100 rounded-full text-sm"
>
{tag}
</span>
))}
</div>
</footer>
<CommentSection postId={post.id} comments={post.comments} />
</article>
)
}
|
9.4 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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
| // app/api/blog/route.ts
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { db } from '@/lib/db'
import { postSchema } from '@/lib/validations'
export async function GET(request: Request) {
const { searchParams } = new URL(request.url)
const page = parseInt(searchParams.get('page') || '1')
const limit = parseInt(searchParams.get('limit') || '10')
const posts = await db.post.findMany({
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' }
})
return NextResponse.json(posts)
}
export async function POST(request: Request) {
const session = await getServerSession(authOptions)
if (!session) {
return NextResponse.json(
{ error: '未授权' },
{ status: 401 }
)
}
const body = await request.json()
const validatedData = postSchema.parse(body)
const post = await db.post.create({
data: {
...validatedData,
authorId: session.user.id
}
})
return NextResponse.json(post, { status: 201 })
}
|
十、性能优化#
10.1 图片优化#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| import Image from 'next/image'
export default function ProductPage() {
return (
<Image
src="/product.jpg"
alt="产品图片"
width={800}
height={600}
priority // 首屏图片优先加载
placeholder="blur" // 模糊占位符
blurDataURL="data:image/jpeg;base64,..." // 模糊数据
/>
)
}
|
10.2 字体优化#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // app/layout.tsx
import { Inter } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
display: 'swap',
variable: '--font-inter'
})
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN" className={inter.variable}>
<body className={inter.className}>
{children}
</body>
</html>
)
}
|
10.3 动态导入#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
| // app/dashboard/page.tsx
import dynamic from 'next/dynamic'
// 动态导入重型组件
const Chart = dynamic(() => import('@/components/Chart'), {
loading: () => <ChartSkeleton />,
ssr: false // 仅客户端渲染
})
const RichTextEditor = dynamic(() => import('@/components/RichTextEditor'), {
loading: () => <div>加载中...</div>
})
export default function DashboardPage() {
return (
<div>
<Chart />
<RichTextEditor />
</div>
)
}
|
Next.js 15 的 App Router 为构建现代化 Web 应用提供了强大而优雅的解决方案。通过本文的实战指南,您应该能够:
- 理解核心概念:掌握 App Router 的路由系统和文件组织
- 服务器组件:合理使用 RSC 和客户端组件
- 数据管理:实现高效的数据获取和缓存策略
- 用户体验:提供流畅的加载和错误处理
- 性能优化:应用各种优化技巧提升应用性能
下一步建议:
开始构建您的 Next.js 应用吧!
延伸阅读: