Next.js 完全指南
Next.js 14+ 是基于 React 的全栈框架,App Router 是其核心架构,内置 React Server Components、流式渲染、Server Actions 等能力。 相关文档:React完全指南(/react-wan-quan-zhi-nan/) | TypeScript完全指南(/typescript-wan-quan-zhi-nan/) 默认情况下 app/ 下的所有组件都是 Server Components,在服务端渲染后发送 HTML 给客户端,不包含客户端 JavaScript bundle。 在文件顶部添加 '
官方文档:https://nextjs.org/docs
适用版本:Next.js 15(2026-05-07 核实)
Next.js 14+ 是基于 React 的全栈框架,App Router 是其核心架构,内置 React Server Components、流式渲染、Server Actions 等能力。
相关文档:React完全指南 | TypeScript完全指南
核心概念
App Router vs Pages Router
| 维度 | App Router(推荐) | Pages Router(旧) |
|---|---|---|
| 目录 | app/ |
pages/ |
| 默认组件类型 | Server Component | Client Component |
| 数据获取方式 | async/await 直接在组件中使用 |
getServerSideProps / getStaticProps |
| 布局 | 嵌套 layout.tsx 文件 |
_app.tsx 单一全局布局 |
| 流式渲染 | 原生支持(loading.tsx / Suspense) |
不支持 |
| Server Actions | 支持 | 不支持 |
| Metadata API | export const metadata / generateMetadata() |
<Head> 组件 |
Server Components vs Client Components
默认情况下 app/ 下的所有组件都是 Server Components,在服务端渲染后发送 HTML 给客户端,不包含客户端 JavaScript bundle。
在文件顶部添加 'use client' 声明变为 Client Component,拥有完整的 React 能力(状态、事件、Effect)。
| 特性 | Server Component | Client Component |
|---|---|---|
| 声明方式 | 默认(无需声明) | 文件顶部 'use client' |
| 运行环境 | 服务端 | 客户端(也会服务端预渲染) |
| 直接访问数据库/文件系统 | 可以 | 不可以 |
使用 useState / useEffect |
不可以 | 可以 |
| 使用浏览器 API | 不可以 | 可以 |
接收事件处理器(onClick 等) |
不可以 | 可以 |
| 减少客户端 bundle | 是 | 否 |
// app/page.tsx - 默认是 Server Component
async function HomePage() {
// 可以直接访问数据库,无需 API 层
const posts = await db.posts.findMany()
return <PostList posts={posts} />
}
// components/Counter.tsx - Client Component
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
渲染策略
| 策略 | 触发条件 | 说明 |
|---|---|---|
| Static | 无动态数据获取 | 构建时生成,CDN 缓存,性能最优 |
| Dynamic | 使用 cookies()、headers()、searchParams,或 cache: 'no-store' |
每次请求时在服务端渲染 |
| Streaming | 使用 Suspense 包裹组件 |
逐步发送 HTML,提升首屏感知速度 |
文件约定
app/ 目录文件约定
| 文件 | 说明 |
|---|---|
page.tsx |
路由的 UI 页面(必须导出默认组件才能访问该路由) |
layout.tsx |
该路由及其子路由共享的布局,不因路由变化重新渲染 |
loading.tsx |
基于 Suspense 的加载 UI,数据获取期间自动展示 |
error.tsx |
错误边界,必须是 Client Component('use client') |
not-found.tsx |
404 页面,由 notFound() 函数触发或路由不存在时展示 |
route.ts |
API 端点(Route Handler),不能与同路径的 page.tsx 共存 |
template.tsx |
类似 layout.tsx,但每次路由变化都重新挂载 |
default.tsx |
Parallel Routes 的默认内容 |
middleware.ts |
只能放在项目根目录(与 app/ 同级) |
动态路由
| 文件路径 | 匹配路由 | params 类型 |
|---|---|---|
app/blog/[id]/page.tsx |
/blog/123 |
{ id: string } |
app/blog/[...slug]/page.tsx |
/blog/a/b/c |
{ slug: string[] } |
app/blog/...slug/page.tsx |
/blog 或 /blog/a/b |
{ slug?: string[] } |
// app/blog/[id]/page.tsx
interface Props {
params: { id: string }
searchParams: { [key: string]: string | string[] | undefined }
}
export default async function BlogPost({ params, searchParams }: Props) {
const post = await getPost(params.id)
return <article>{post.content}</article>
}
Route Groups
用括号包裹目录名(如 (marketing)),该目录不会出现在 URL 中,仅用于组织文件结构。
app/
(marketing)/
about/page.tsx -> /about
layout.tsx -> 仅用于 marketing 组
(shop)/
products/page.tsx -> /products
layout.tsx -> 仅用于 shop 组
Parallel Routes 和 Intercepting Routes
Parallel Routes(并行路由):用 @slot 目录名定义,在同一个 layout.tsx 中同时渲染多个页面。
app/
layout.tsx -> 引用 @dashboard 和 @analytics
@dashboard/
page.tsx
@analytics/
page.tsx
page.tsx
// app/layout.tsx
export default function Layout({
children,
dashboard,
analytics,
}: {
children: React.ReactNode
dashboard: React.ReactNode
analytics: React.ReactNode
}) {
return (
<div>
{children}
{dashboard}
{analytics}
</div>
)
}
Intercepting Routes(拦截路由):在不离开当前页面的情况下展示另一个路由的内容(如在列表页弹出详情 Modal)。
| 约定 | 说明 |
|---|---|
(.)photo/[id] |
拦截同级路由 |
(..)photo/[id] |
拦截上一层路由 |
(...)photo/[id] |
拦截根路由 |
数据获取
Server Component 直接 async/await
在 Server Component 中可以直接用 async/await 获取数据,无需任何 Hook:
// app/posts/page.tsx
async function PostsPage() {
// 这段代码只在服务端运行,可以直接访问数据库
const posts = await fetch('https://api.example.com/posts').then(r => r.json())
return (
<ul>
{posts.map((post: Post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
)
}
fetch 缓存选项
Next.js 扩展了原生 fetch,增加了缓存控制选项。
| 选项 | 行为 |
|---|---|
cache: 'force-cache' |
默认值,强制缓存,等同于静态数据 |
cache: 'no-store' |
不缓存,每次请求都重新获取(动态数据) |
next: { revalidate: 60 } |
ISR 策略,60 秒后缓存失效,下次请求时重新生成 |
next: { tags: ['posts'] } |
按标签缓存,配合 revalidateTag('posts') 按需清除 |
// 静态数据:构建时获取,长期缓存
const staticData = await fetch('/api/config', { cache: 'force-cache' })
// 动态数据:每次请求都获取最新
const dynamicData = await fetch('/api/user', { cache: 'no-store' })
// ISR:定时重新验证
const revalidatedData = await fetch('/api/posts', { next: { revalidate: 3600 } })
generateStaticParams
在动态路由中使用 generateStaticParams,在构建时预生成静态页面:
// app/blog/[id]/page.tsx
export async function generateStaticParams() {
const posts = await fetch('/api/posts').then(r => r.json())
return posts.map((post: Post) => ({
id: post.id.toString(),
}))
}
// 访问未预生成的路径时的行为
export const dynamicParams = true // 动态生成(默认)
// export const dynamicParams = false // 返回 404
Server Actions
Server Actions 允许在客户端组件中直接调用服务端函数,主要用于表单提交和数据变更。
// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await db.posts.create({ data: { title } })
revalidatePath('/posts') // 清除缓存,触发页面重新生成
}
// app/posts/new/page.tsx - 可以是 Server Component
import { createPost } from '../actions'
export default function NewPost() {
return (
<form action={createPost}>
<input name="title" />
<button type="submit">发布</button>
</form>
)
}
在 Client Component 中使用 Server Actions:
'use client'
import { createPost } from '../actions'
import { useTransition } from 'react'
export function PostForm() {
const [isPending, startTransition] = useTransition()
function handleSubmit(formData: FormData) {
startTransition(async () => {
await createPost(formData)
})
}
return (
<form action={handleSubmit}>
<input name="title" />
<button disabled={isPending}>
{isPending ? '发布中...' : '发布'}
</button>
</form>
)
}
API Routes
Route Handler(app/api/xxx/route.ts)
// app/api/posts/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams
const page = searchParams.get('page') ?? '1'
const posts = await getPosts({ page: parseInt(page) })
return NextResponse.json(posts)
}
export async function POST(request: NextRequest) {
const body = await request.json()
const post = await createPost(body)
return NextResponse.json(post, { status: 201 })
}
动态路由 Route Handler:
// app/api/posts/[id]/route.ts
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
const post = await getPost(params.id)
if (!post) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(post)
}
NextRequest 常用属性
| 属性/方法 | 说明 |
|---|---|
request.url |
完整 URL 字符串 |
request.nextUrl |
扩展的 URL 对象(含 pathname、searchParams 等) |
request.nextUrl.searchParams |
URL 查询参数(URLSearchParams 实例) |
request.method |
HTTP 方法 |
request.headers |
请求头(Headers 实例) |
request.cookies |
请求 Cookie |
request.json() |
解析 JSON 请求体(异步) |
request.text() |
读取文本请求体(异步) |
request.formData() |
解析 FormData(异步) |
NextResponse 常用方法
| 方法 | 说明 |
|---|---|
NextResponse.json(data, init?) |
返回 JSON 响应 |
NextResponse.redirect(url, init?) |
重定向 |
NextResponse.rewrite(url) |
内部重写(URL 不变) |
NextResponse.next() |
中间件中继续处理请求 |
response.cookies.set(name, value, opts?) |
在响应上设置 Cookie |
response.cookies.delete(name) |
删除 Cookie |
中间件(middleware.ts)
中间件运行在所有路由之前,必须放在项目根目录(与 app/ 同级)。
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')
if (!token && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
// 配置匹配规则(不配置则匹配所有路由)
export const config = {
matcher: ['/dashboard/:path*', '/api/protected/:path*'],
}
性能优化
next/image
<Image> 组件自动进行图片优化:懒加载、格式转换(WebP/AVIF)、尺寸调整、避免 CLS。
| 属性 | 类型 | 必填 | 说明 |
|---|---|---|---|
src |
string | StaticImageData |
是 | 图片路径或导入的静态图片 |
alt |
string |
是 | 无障碍描述 |
width |
number |
是(非 fill) | 显示宽度(px) |
height |
number |
是(非 fill) | 显示高度(px) |
fill |
boolean |
— | 填充父容器(父容器需 position: relative) |
sizes |
string |
— | 响应式图片尺寸提示(类似 <img sizes>) |
priority |
boolean |
— | 预加载,用于 LCP 图片,禁用懒加载 |
quality |
number |
— | 压缩质量(1-100,默认 75) |
placeholder |
'blur' | 'empty' | 'data:...' |
— | 加载占位符 |
blurDataURL |
string |
— | placeholder="blur" 时的模糊预览 base64 |
loading |
'lazy' | 'eager' |
— | 加载策略(默认 lazy) |
unoptimized |
boolean |
— | 跳过 Next.js 图片优化 |
style |
object |
— | 内联样式 |
className |
string |
— | CSS 类名 |
onLoad |
function |
— | 图片加载完成回调 |
onError |
function |
— | 图片加载失败回调 |
import Image from 'next/image'
import heroImg from '@/public/hero.jpg'
// 固定尺寸
<Image src={heroImg} alt="Hero" width={800} height={400} priority />
// 响应式填充
<div style={{ position: 'relative', height: '400px' }}>
<Image
src="/banner.jpg"
alt="Banner"
fill
sizes="(max-width: 768px) 100vw, 50vw"
style={{ objectFit: 'cover' }}
/>
</div>
next/font
自动优化字体加载,消除布局偏移,字体文件托管在本地。
// app/layout.tsx
import { Inter, Noto_Sans_SC } from 'next/font/google'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
})
const notoSansSC = Noto_Sans_SC({
subsets: ['chinese-simplified'],
weight: ['400', '700'],
variable: '--font-noto',
})
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh" className={`${inter.variable} ${notoSansSC.variable}`}>
<body>{children}</body>
</html>
)
}
next/link
<Link> 组件在视口内自动预取页面资源,实现无感知导航。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
href |
string | UrlObject |
— | 目标路由(必填) |
replace |
boolean |
false |
替换 history 记录而非 push |
scroll |
boolean |
true |
导航后滚动到页面顶部 |
prefetch |
boolean | null |
null |
null 视口预取,true 强制预取,false 禁用 |
shallow |
boolean |
— | Pages Router 选项,App Router 不适用 |
import Link from 'next/link'
<Link href="/about">关于</Link>
<Link href={{ pathname: '/blog/[id]', query: { id: '123' } }}>文章</Link>
<Link href="/external" prefetch={false}>不预取此链接</Link>
最佳实践
Server / Client 组件边界设计
将 Client Component 尽量下移到叶节点,让大部分组件保持 Server Component:
app/page.tsx (Server)
└── PostList (Server) - 获取数据、渲染列表
└── PostCard (Server) - 展示卡片内容
└── LikeButton (Client) - 只有这个需要交互
数据获取放在尽可能靠近使用的位置
// 推荐:在用到数据的 Server Component 内部直接获取
async function PostCard({ postId }: { postId: string }) {
const post = await getPost(postId) // 靠近使用位置
return <div>{post.title}</div>
}
// 不推荐:在顶层获取所有数据再逐层传递(prop drilling)
踩坑与注意事项
Server / Client 边界错误
Server Component 不能导入 Client Component 中的浏览器 API,但 Client Component 可以导入 Server Component(作为 children props 传入)。
// 错误:Server Component 中使用 useState
// app/page.tsx(Server Component)
import { useState } from 'react' // 报错:不能在 Server Component 使用
// 正确:只在有 'use client' 的文件中使用
Context Provider 必须是 Client Component,但可以包裹 Server Component:
// providers.tsx
'use client'
import { ThemeProvider } from './theme-context'
export function Providers({ children }: { children: React.ReactNode }) {
return <ThemeProvider>{children}</ThemeProvider>
}
// layout.tsx(Server Component)
import { Providers } from './providers'
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
<Providers>{children}</Providers> {/* children 仍是 Server Component */}
</body>
</html>
)
}
cookies() 和 headers() 的使用限制
cookies() 和 headers() 来自 next/headers,只能在以下位置使用:
| 可以使用 | 不能使用 |
|---|---|
| Server Component | Client Component |
Route Handler(route.ts) |
generateStaticParams |
| Server Action | 静态渲染的布局/页面(使用后会强制转为动态渲染) |
Middleware(使用 request.cookies / request.headers) |
— |
// app/profile/page.tsx
import { cookies, headers } from 'next/headers'
export default async function ProfilePage() {
// 使用 cookies() 会使这个页面变为动态渲染
const cookieStore = cookies()
const token = cookieStore.get('auth-token')
const headersList = headers()
const userAgent = headersList.get('user-agent')
// ...
}
notFound() 必须在 Server Component 或 Route Handler 中调用
import { notFound } from 'next/navigation'
export default async function PostPage({ params }: { params: { id: string } }) {
const post = await getPost(params.id)
if (!post) {
notFound() // 触发同级或最近父级的 not-found.tsx
}
return <article>{post.content}</article>
}
Streaming 与 error.tsx
error.tsx 必须是 Client Component(需要使用 useEffect 访问错误对象),且只能捕获其子树内的错误,不能捕获同级 layout.tsx 的错误。
// app/blog/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>
<p>出错了</p>
<button onClick={reset}>重试</button>
</div>
)
}
最佳实践
默认使用 Server Components,仅在需要时降级为 Client Components:Server Components 在服务端渲染,零 JS bundle,可直接访问数据库和文件系统。只有需要浏览器 API、事件处理、useState/useEffect 时才加 'use client'。
// Server Component(默认,不需要声明)
async function UserList() {
const users = await db.query('SELECT * FROM users') // 直接访问数据库
return <ul>{users.map(u => <li key={u.id}>{u.name}</li>)}</ul>
}
// Client Component(仅在需要交互时)
'use client'
function Counter() {
const [count, setCount] = useState(0)
return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}
静态内容用 SSG,动态内容用 ISR,实时内容用 SSR:按数据更新频率选择渲染策略,避免把所有页面都做成 SSR。
// ISR:每 60 秒重新生成(适合博客、产品页)
export const revalidate = 60
// SSG:构建时生成,永不过期(适合文档、静态页)
export const revalidate = false
// SSR:每次请求重新渲染(适合个性化、实时数据)
export const dynamic = 'force-dynamic'
数据变更用 Server Actions,避免手动维护 API Route:Server Actions 可直接从 Client Component 调用服务端逻辑,无需创建 /api 路由,并自动处理 CSRF 保护。
// app/actions.ts
'use server'
export async function createPost(formData: FormData) {
const title = formData.get('title') as string
await db.insert({ title })
revalidatePath('/posts')
}
// 在 Client Component 中直接调用
<form action={createPost}>
<input name="title" />
<button type="submit">发布</button>
</form>
使用 next/image 替代原生 <img>:自动优化图片格式(WebP/AVIF)、按需懒加载、防止 CLS(布局偏移)。
import Image from 'next/image'
// 正确:自动优化
<Image src="/hero.jpg" width={800} height={400} alt="hero" priority />
// 错误:跳过优化
<img src="/hero.jpg" />
路由分组用 (folder) 避免 URL 污染:括号命名的目录不影响 URL 路径,用于组织布局而不暴露目录结构。
app/
(marketing)/ ← URL 中不出现 "marketing"
layout.tsx ← 只对 marketing 页面生效的布局
page.tsx ← 对应 /
(dashboard)/
layout.tsx
analytics/page.tsx ← 对应 /analytics
常见陷阱
陷阱:在 Server Component 中使用浏览器 API
现象: 构建报错 "ReferenceError: window is not defined" 或 "localStorage is not defined"。
原因: Server Components 在 Node.js 中执行,没有浏览器环境,无法访问 window、document、localStorage 等。
解决: 将使用浏览器 API 的代码移到 Client Component(添加 'use client'),或使用动态导入加 ssr: false。
// 动态导入,禁用 SSR
import dynamic from 'next/dynamic'
const BrowserOnlyChart = dynamic(() => import('./Chart'), { ssr: false })
陷阱:Hydration 错误(水合不匹配)
现象: 控制台报 "Hydration failed because the initial UI does not match what was rendered on the server",页面出现闪烁或内容重置。
原因: 服务端渲染的 HTML 与客户端 React 首次渲染的结果不一致,常见原因是在渲染时读取了只在客户端可用的值(如 Date.now()、Math.random()、localStorage)。
解决: 将依赖客户端状态的渲染延迟到 useEffect 后,或使用 suppressHydrationWarning(仅对已知安全的不一致使用)。
'use client'
const [mounted, setMounted] = useState(false)
useEffect(() => setMounted(true), [])
if (!mounted) return null // 服务端渲染返回 null,避免不一致
return <div>{localStorage.getItem('theme')}</div>
陷阱:fetch 缓存导致数据过期
现象: 更新了数据库数据,但页面显示的仍是旧内容,重启服务才恢复正常。
原因: Next.js 扩展了原生 fetch,默认对所有请求使用强缓存(cache: 'force-cache'),即构建后永久缓存。
解决: 显式设置缓存策略,或使用 revalidatePath / revalidateTag 手动清除缓存。
// 不缓存
fetch(url, { cache: 'no-store' })
// ISR:60 秒后重新验证
fetch(url, { next: { revalidate: 60 } })
// 带 tag 的缓存,可按需清除
fetch(url, { next: { tags: ['posts'] } })
// 在 Server Action 中清除
revalidateTag('posts')