引言:Server Actions 为什么让全栈开发变了天
说实话,在传统 React 全栈应用里,前后端之间的数据交互一直是件挺让人头疼的事。你得定义 API 路由、写请求处理逻辑、在客户端发 fetch 请求、还得管理加载状态和错误处理——整套流程走下来,光是样板代码就能写到手软。
但这一切在 Next.js App Router 引入 Server Actions 之后,彻底不一样了。
Server Actions 是 React 19 中正式稳定的服务器端函数,简单来说,它允许你直接在组件中调用服务器逻辑,不用再手动创建 API 端点。Next.js 15 及更高版本对这个特性做了深度整合,数据变更(mutations)变得前所未有地简洁。到 2026 年初,Server Actions 基本上已经成了 Next.js 应用里处理表单提交、数据库操作的标配了。
这篇文章会从底层原理开始,一步步拆解 Server Actions 的工作机制。我们会聊到类型安全验证、表单处理、乐观更新、缓存重验证,还有——这个很关键——安全防护策略,包括 2025 年底那个震动整个 React 生态的 React2Shell 远程代码执行漏洞(CVE-2025-55182)。不管你是刚上手 Server Actions 的新人,还是想优化现有实现的老手,应该都能在这篇指南里找到有用的东西。
第一章:Server Actions 的核心原理
1.1 什么是 Server Actions?
Server Actions 本质上就是用 "use server" 指令标记的异步函数。它们始终在服务器上执行,但可以从客户端组件或服务器组件中调用。Next.js 在编译时会自动为每个 Server Action 生成一个唯一的、不可猜测的端点 ID,客户端通过 POST 请求调用这些端点,框架帮你搞定参数序列化和返回值反序列化。
听起来很抽象?看段代码就清楚了:
// app/actions.ts
"use server"
export async function createPost(formData: FormData) {
const title = formData.get("title") as string
const content = formData.get("content") as string
// 直接执行数据库操作,无需 API 路由
await db.post.create({
data: { title, content }
})
revalidatePath("/posts")
}
没有 fetch,没有 API route,直接调用就完事了。第一次看到这个写法的时候我也有点不敢相信,但它确实就是这么简洁。
1.2 工作流程详解
当用户在客户端触发一个 Server Action 时,背后发生了什么?完整流程大概是这样的:
- 客户端序列化:React 将函数参数(比如 FormData)序列化为请求体
- POST 请求:框架自动向服务器发送 POST 请求,带上 Action ID 和序列化后的参数
- 服务器执行:Next.js 根据 Action ID 找到对应函数并执行
- 响应返回:执行结果通过 RSC(React Server Components)协议返回客户端
- UI 更新:React 据此更新界面,包括重新渲染受影响的服务器组件
有个细节值得一提:在 Next.js 15 中,如果某个 Server Action 没被用到,它的 ID 根本不会出现在客户端 JavaScript bundle 里。这不仅减小了包体积,安全性也更好。而且 Action ID 用的是不可预测的非确定性算法,每次构建都会重新生成。
1.3 Server Actions vs API Routes:到底该选哪个?
这可能是最常被问到的问题了。两者各有适用场景:
- 选 Server Actions:表单提交、数据增删改、文件上传、发邮件——基本上所有跟用户交互直接相关的操作
- 选 API Routes:需要被外部服务调用的 API、Webhook 端点、需要自定义 HTTP 方法(比如 PUT、DELETE)的接口、流式响应
- 数据读取:纯粹的数据获取,优先用 Server Components 直接在组件里拿数据,别用 Server Actions
简单记就是:写操作用 Server Actions,读操作用 Server Components,对外暴露的接口用 API Routes。
第二章:Server Actions 的定义与组织方式
2.1 内联定义
在服务器组件中,你可以直接内联定义 Server Action,代码很直观:
// app/posts/page.tsx (Server Component)
export default function PostsPage() {
async function deletePost(formData: FormData) {
"use server"
const id = formData.get("id") as string
await db.post.delete({ where: { id } })
revalidatePath("/posts")
}
return (
<form action={deletePost}>
<input type="hidden" name="id" value="123" />
<button type="submit">删除文章</button>
</form>
)
}
2.2 独立文件组织(推荐做法)
对于稍微大一点的项目,我个人强烈推荐把 Server Actions 抽到专门的文件里。好处很明显:代码复用更方便、关注点分离更清晰,测试和维护也更省心。
// app/actions/post.ts
"use server"
import { revalidatePath } from "next/cache"
import { redirect } from "next/navigation"
import { db } from "@/lib/db"
export async function createPost(formData: FormData) {
const title = formData.get("title") as string
const content = formData.get("content") as string
const post = await db.post.create({
data: { title, content, authorId: "current-user-id" }
})
revalidatePath("/posts")
redirect(`/posts/${post.id}`)
}
export async function updatePost(id: string, formData: FormData) {
const title = formData.get("title") as string
const content = formData.get("content") as string
await db.post.update({
where: { id },
data: { title, content }
})
revalidatePath(`/posts/${id}`)
}
export async function deletePost(id: string) {
await db.post.delete({ where: { id } })
revalidatePath("/posts")
redirect("/posts")
}
2.3 在客户端组件中调用
Server Actions 也能在客户端组件里导入调用,这在需要交互反馈的场景下特别好用:
// app/components/LikeButton.tsx
"use client"
import { useTransition } from "react"
import { likePost } from "@/app/actions/post"
export function LikeButton({ postId }: { postId: string }) {
const [isPending, startTransition] = useTransition()
return (
<button
disabled={isPending}
onClick={() => {
startTransition(async () => {
await likePost(postId)
})
}}
>
{isPending ? "点赞中..." : "👍 点赞"}
</button>
)
}
注意这里用了 useTransition 来处理加载状态,这是一种很优雅的方式。用户点击按钮后会立刻看到"点赞中..."的反馈,不会有那种"点了没反应"的尴尬。
第三章:表单处理与 useActionState
3.1 用 useActionState 管理表单状态
React 19 引入的 useActionState 钩子是处理 Server Action 表单的官方推荐方式。它把提交结果、错误信息和加载状态都统一管理了,代码写起来清爽很多。
// app/actions/auth.ts
"use server"
type ActionState = {
success: boolean
message: string
errors?: Record<string, string[]>
}
export async function registerUser(
prevState: ActionState,
formData: FormData
): Promise<ActionState> {
const email = formData.get("email") as string
const password = formData.get("password") as string
// 服务器端验证
if (!email || !email.includes("@")) {
return {
success: false,
message: "验证失败",
errors: { email: ["请输入有效的邮箱地址"] }
}
}
if (!password || password.length < 8) {
return {
success: false,
message: "验证失败",
errors: { password: ["密码至少需要8个字符"] }
}
}
try {
await db.user.create({
data: { email, password: await hashPassword(password) }
})
return { success: true, message: "注册成功!" }
} catch (error) {
return { success: false, message: "注册失败,请稍后再试" }
}
}
// app/register/page.tsx
"use client"
import { useActionState } from "react"
import { registerUser } from "@/app/actions/auth"
const initialState = { success: false, message: "" }
export default function RegisterPage() {
const [state, formAction, isPending] = useActionState(
registerUser,
initialState
)
return (
<form action={formAction}>
<div>
<label htmlFor="email">邮箱</label>
<input id="email" name="email" type="email" required />
{state.errors?.email && (
<p className="error">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="password">密码</label>
<input id="password" name="password" type="password" required />
{state.errors?.password && (
<p className="error">{state.errors.password[0]}</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? "注册中..." : "注册"}
</button>
{state.message && (
<p className={state.success ? "success" : "error"}>
{state.message}
</p>
)}
</form>
)
}
3.2 渐进增强:没 JavaScript 也能跑
这是 Server Actions 一个经常被忽略但其实很厉害的优势——天然支持渐进增强。哪怕 JavaScript 还没加载完或者直接挂了,表单依然能正常提交处理。
useActionState 有个关键特性:服务器的响应可以在客户端水合(hydration)完成之前就展示出来。这意味着在低端设备或慢网环境下,用户体验不会因为 JS 加载慢而大打折扣。老实说,这一点在实际项目中比想象的重要得多。
第四章:类型安全验证 —— Zod 与 next-safe-action
4.1 用 Zod 做输入验证
永远不要信任来自客户端的数据。这句话怎么强调都不为过。Server Actions 接收的参数必须在服务器端做严格验证,Zod 是目前最流行的选择:
// lib/validations/post.ts
import { z } from "zod"
export const createPostSchema = z.object({
title: z
.string()
.min(1, "标题不能为空")
.max(200, "标题不能超过200个字符"),
content: z
.string()
.min(10, "内容至少需要10个字符"),
categoryId: z
.string()
.uuid("无效的分类ID"),
tags: z
.array(z.string())
.max(5, "最多选择5个标签")
.optional()
})
export type CreatePostInput = z.infer<typeof createPostSchema>
// app/actions/post.ts
"use server"
import { createPostSchema } from "@/lib/validations/post"
export async function createPost(formData: FormData) {
const rawData = {
title: formData.get("title"),
content: formData.get("content"),
categoryId: formData.get("categoryId"),
tags: formData.getAll("tags")
}
const result = createPostSchema.safeParse(rawData)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors
}
}
// result.data 现在是完全类型安全的
const post = await db.post.create({
data: result.data
})
revalidatePath("/posts")
return { success: true, postId: post.id }
}
4.2 用 next-safe-action 实现端到端类型安全
next-safe-action 是专门为 Next.js Server Actions 设计的类型安全库。坦白说,用了它之后你会觉得之前手写那些验证和错误处理代码都是在浪费生命。它的 API 非常优雅:
// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action"
import { getSession } from "@/lib/auth"
export const actionClient = createSafeActionClient({
handleServerError(error) {
console.error("Action error:", error.message)
return "服务器错误,请稍后再试"
}
})
// 带认证的 action client
export const authActionClient = actionClient.use(async ({ next }) => {
const session = await getSession()
if (!session?.user) {
throw new Error("未登录")
}
return next({ ctx: { user: session.user } })
})
// app/actions/post.ts
"use server"
import { authActionClient } from "@/lib/safe-action"
import { createPostSchema } from "@/lib/validations/post"
import { revalidatePath } from "next/cache"
export const createPost = authActionClient
.schema(createPostSchema)
.action(async ({ parsedInput, ctx }) => {
const post = await db.post.create({
data: {
...parsedInput,
authorId: ctx.user.id
}
})
revalidatePath("/posts")
return { postId: post.id }
})
// 在客户端组件中使用
"use client"
import { useAction } from "next-safe-action/hooks"
import { createPost } from "@/app/actions/post"
export function CreatePostForm() {
const { execute, result, isExecuting } = useAction(createPost)
return (
<form
onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
execute({
title: formData.get("title") as string,
content: formData.get("content") as string,
categoryId: formData.get("categoryId") as string
})
}}
>
{/* 表单字段 */}
{result.validationErrors && (
<div className="errors">
{Object.entries(result.validationErrors).map(([field, errors]) => (
<p key={field}>{field}: {errors?.join(", ")}</p>
))}
</div>
)}
{result.serverError && (
<p className="error">{result.serverError}</p>
)}
<button disabled={isExecuting}>
{isExecuting ? "发布中..." : "发布文章"}
</button>
</form>
)
}
next-safe-action 的核心价值在于:它不只是做 Zod 验证错误的结构化返回,还统一处理服务器错误和网络错误,整个数据流从输入到输出都是完全类型安全的。从 v6 版本开始,它还支持任何实现了 Standard Schema 规范的验证库(Valibot、Yup 等都行),灵活度很高。
第五章:乐观更新与缓存重验证
5.1 用 useOptimistic 实现即时反馈
如果说有什么技术能让你的应用"感觉快了一倍",那一定是乐观更新。通过 React 19 的 useOptimistic 钩子,你可以在 Server Action 还没执行完的时候就先把 UI 更新了,用户体验瞬间流畅很多。
// app/components/TodoList.tsx
"use client"
import { useOptimistic } from "react"
import { addTodo } from "@/app/actions/todo"
type Todo = {
id: string
text: string
completed: boolean
}
export function TodoList({ todos }: { todos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state: Todo[], newTodo: Todo) => [...state, newTodo]
)
async function handleAddTodo(formData: FormData) {
const text = formData.get("text") as string
// 立即在 UI 中显示新的 todo(乐观更新)
addOptimisticTodo({
id: "temp-" + Date.now(),
text,
completed: false
})
// 然后执行实际的 Server Action
await addTodo(formData)
}
return (
<div>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} style={{
opacity: todo.id.startsWith("temp-") ? 0.6 : 1
}}>
{todo.text}
</li>
))}
</ul>
<form action={handleAddTodo}>
<input name="text" placeholder="添加新任务..." required />
<button type="submit">添加</button>
</form>
</div>
)
}
注意看那个 opacity 的处理——乐观更新的条目会稍微透明一些,给用户一个视觉暗示"这个还在处理中"。小细节,但体验上差别很大。
5.2 缓存重验证策略
Server Action 改了数据之后,得确保缓存里的旧数据也跟着更新。Next.js 提供了几个核心 API 来处理这个问题。
revalidatePath:按路径让特定页面或布局的缓存失效。
"use server"
import { revalidatePath } from "next/cache"
export async function updateProfile(formData: FormData) {
await db.user.update({ /* ... */ })
// 使特定路径的缓存失效
revalidatePath("/profile")
// 使特定动态路由的缓存失效
revalidatePath("/posts/[slug]", "page")
// 使布局及其所有子页面的缓存失效
revalidatePath("/dashboard", "layout")
}
revalidateTag:按标签让相关数据缓存失效,跨页面数据更新的时候特别好用。
"use server"
import { revalidateTag } from "next/cache"
export async function publishPost(id: string) {
await db.post.update({
where: { id },
data: { status: "published" }
})
// 使所有带有 "posts" 标签的缓存失效
revalidateTag("posts")
// 使特定文章的缓存失效
revalidateTag(`post-${id}`)
}
updateTag(2026 年新增):这个是专门给 Server Actions 设计的即时缓存过期 API。跟 revalidateTag 不太一样,updateTag 只能在 Server Actions 中使用,而且会立即让缓存条目过期(不是后台慢慢来),对"读自己写"(read-your-own-writes)的场景特别有用。
"use server"
import { updateTag } from "next/cache"
export async function updateCartItem(itemId: string, quantity: number) {
await db.cartItem.update({
where: { id: itemId },
data: { quantity }
})
// 立即使购物车缓存过期,下次请求将执行阻塞式重新验证
updateTag("cart")
}
第六章:安全防护 —— 从 CSRF 到 React2Shell
6.1 内置安全机制
Next.js 为 Server Actions 提供了好几层内置安全防护,先来看看框架帮你做了什么:
- 仅限 POST 方法:Server Actions 只能通过 POST 请求调用,配合现代浏览器默认的 SameSite Cookie 策略,大部分 CSRF 攻击就被挡在门外了
- Origin 校验:Next.js 会比较请求的 Origin 头与 Host 头(或 X-Forwarded-Host),不匹配直接拒绝
- 不可预测的 Action ID:端点用的是加密安全的随机 ID,每次构建都重新生成
- Dead Code Elimination:没用到的 Server Actions 压根不会出现在客户端 bundle 里
6.2 React2Shell 漏洞:一个沉痛的教训
2025 年 12 月,React 生态遭遇了一次重大安全事件——React2Shell 漏洞(CVE-2025-55182 / CVE-2025-66478)。CVSS 评分直接拉满 10.0,这是一个远程代码执行(RCE)漏洞,影响所有使用 React Server Components 的应用,包括 Next.js App Router 项目。
漏洞原理:攻击者向 React Server Function 端点发送精心构造的 POST 请求,利用 RSC 协议中的不安全反序列化,让服务器执行恶意代码。最可怕的是什么呢?用 create-next-app 创建的默认配置项目,不做任何修改就能被利用,攻击成功率接近 100%。
影响范围:
- React Server Components 19.0.0 – 19.2.2
- Next.js 14.3.0-canary.77 及以上、15.x、16.x 的未修补版本
修复方案:
# 检查你的 Next.js 版本
npx next --version
# 升级到已修补的版本
npm install [email protected] # 或 15.4.8, 15.3.6, 15.2.6, 15.1.9, 15.0.5, 16.0.7
# 同时升级 React
npm install [email protected] [email protected] # 或 19.1.4, 19.2.3
根据 Google Cloud 和 Palo Alto Networks Unit42 的报告,这个漏洞从披露到被大规模利用仅仅过了几个小时。唯一靠谱的修复方法就是立刻升级到已修补版本然后重新部署。WAF 规则?只是临时缓解,攻击者分分钟绕过基于签名的拦截。
6.3 安全最佳实践清单
经历了 React2Shell 事件,安全这根弦得绷得更紧了。以下是 Server Actions 的安全最佳实践:
// ❌ 错误做法:直接暴露内部 ID 和敏感数据
export async function deleteUser(userId: string) {
await db.user.delete({ where: { id: userId } })
}
// ✅ 正确做法:始终验证权限
export async function deleteUser(userId: string) {
const session = await getSession()
if (!session?.user) {
throw new Error("未认证")
}
// 验证用户只能删除自己的账户(或具有管理员权限)
if (session.user.id !== userId && session.user.role !== "admin") {
throw new Error("无权限")
}
await db.user.delete({ where: { id: userId } })
}
- 始终验证输入:用 Zod 或类似库对每个 Server Action 的输入做严格验证,没有例外
- 始终检查认证和授权:别假设调用者就是合法用户,每个 Server Action 都该验证会话和权限
- 别在客户端暴露敏感信息:Server Action 的返回值会发到客户端,千万别返回数据库内部 ID、密钥之类的
- 保持依赖更新:定期跑
npm audit,及时修补安全漏洞 - 使用 Data Access Layer(DAL):把数据访问逻辑封装在独立的数据访问层里,确保所有数据库操作都过一遍权限检查
- 考虑速率限制:登录、注册这类敏感操作一定要加速率限制,不然暴力攻击随便打
第七章:高级模式与实战案例
7.1 文件上传
Server Actions 天然支持 FormData,文件上传因此变得相当简单:
// app/actions/upload.ts
"use server"
import { writeFile } from "fs/promises"
import { join } from "path"
export async function uploadAvatar(formData: FormData) {
const file = formData.get("avatar") as File
if (!file || file.size === 0) {
return { error: "请选择文件" }
}
// 验证文件类型和大小
const allowedTypes = ["image/jpeg", "image/png", "image/webp"]
if (!allowedTypes.includes(file.type)) {
return { error: "仅支持 JPG、PNG 和 WebP 格式" }
}
if (file.size > 5 * 1024 * 1024) {
return { error: "文件大小不能超过 5MB" }
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `avatar-${Date.now()}.${file.type.split("/")[1]}`
const path = join(process.cwd(), "public", "uploads", filename)
await writeFile(path, buffer)
return { success: true, url: `/uploads/${filename}` }
}
7.2 带中间件的 Action 链
用 next-safe-action 的中间件系统,你可以搭建可组合的 Action 管道。认证、日志、速率限制这些横切关注点,都能用中间件链优雅地搞定:
// lib/safe-action.ts
import { createSafeActionClient } from "next-safe-action"
import { headers } from "next/headers"
export const actionClient = createSafeActionClient()
// 添加日志中间件
const withLogging = actionClient.use(async ({ next, clientInput, metadata }) => {
const startTime = performance.now()
const result = await next()
const duration = performance.now() - startTime
console.log(`Action ${metadata?.actionName} 执行耗时: ${duration}ms`)
return result
})
// 添加认证中间件
export const authedAction = withLogging.use(async ({ next }) => {
const session = await getSession()
if (!session) {
throw new Error("请先登录")
}
return next({ ctx: { userId: session.user.id } })
})
// 添加管理员权限中间件
export const adminAction = authedAction.use(async ({ next, ctx }) => {
const user = await db.user.findUnique({
where: { id: ctx.userId }
})
if (user?.role !== "admin") {
throw new Error("需要管理员权限")
}
return next({ ctx: { ...ctx, isAdmin: true } })
})
这种"洋葱模型"的中间件设计,用过 Express 或 Koa 的人应该很熟悉。每一层中间件负责一个关注点,组合起来就是一个完整的权限和日志体系。
7.3 实战案例:完整的评论系统
好了,让我们把前面学到的东西全部整合起来,搭一个完整的评论系统。这个例子会涵盖类型安全验证、认证中间件、乐观更新和缓存重验证。
// lib/validations/comment.ts
import { z } from "zod"
export const commentSchema = z.object({
content: z
.string()
.min(1, "评论不能为空")
.max(1000, "评论不能超过1000个字符"),
postId: z.string().uuid(),
parentId: z.string().uuid().optional()
})
// app/actions/comment.ts
"use server"
import { authedAction } from "@/lib/safe-action"
import { commentSchema } from "@/lib/validations/comment"
import { revalidatePath } from "next/cache"
export const addComment = authedAction
.schema(commentSchema)
.action(async ({ parsedInput, ctx }) => {
const comment = await db.comment.create({
data: {
content: parsedInput.content,
postId: parsedInput.postId,
parentId: parsedInput.parentId,
authorId: ctx.userId
},
include: {
author: { select: { name: true, avatar: true } }
}
})
revalidatePath(`/posts/${parsedInput.postId}`)
return {
id: comment.id,
content: comment.content,
author: comment.author
}
})
export const deleteComment = authedAction
.schema(z.object({ commentId: z.string().uuid() }))
.action(async ({ parsedInput, ctx }) => {
const comment = await db.comment.findUnique({
where: { id: parsedInput.commentId }
})
if (!comment) {
throw new Error("评论不存在")
}
if (comment.authorId !== ctx.userId) {
throw new Error("只能删除自己的评论")
}
await db.comment.delete({
where: { id: parsedInput.commentId }
})
revalidatePath(`/posts/${comment.postId}`)
return { success: true }
})
// app/components/CommentSection.tsx
"use client"
import { useOptimistic, useRef } from "react"
import { useAction } from "next-safe-action/hooks"
import { addComment } from "@/app/actions/comment"
type Comment = {
id: string
content: string
author: { name: string; avatar: string }
}
export function CommentSection({
postId,
comments
}: {
postId: string
comments: Comment[]
}) {
const formRef = useRef<HTMLFormElement>(null)
const [optimisticComments, addOptimistic] = useOptimistic(
comments,
(state: Comment[], newComment: Comment) => [...state, newComment]
)
const { execute, isExecuting } = useAction(addComment, {
onSuccess() {
formRef.current?.reset()
},
onError({ error }) {
// 乐观更新失败时,页面会自动通过 revalidation 恢复
console.error("发表评论失败:", error)
}
})
function handleSubmit(formData: FormData) {
const content = formData.get("content") as string
addOptimistic({
id: "temp-" + Date.now(),
content,
author: { name: "你", avatar: "/default-avatar.png" }
})
execute({ content, postId })
}
return (
<section>
<h3>评论 ({optimisticComments.length})</h3>
<ul>
{optimisticComments.map((comment) => (
<li key={comment.id}>
<strong>{comment.author.name}</strong>
<p>{comment.content}</p>
</li>
))}
</ul>
<form ref={formRef} action={handleSubmit}>
<textarea
name="content"
placeholder="发表你的评论..."
required
maxLength={1000}
/>
<button type="submit" disabled={isExecuting}>
{isExecuting ? "发送中..." : "发表评论"}
</button>
</form>
</section>
)
}
第八章:性能优化与调试技巧
8.1 减少序列化开销
Server Actions 的参数和返回值都要经过序列化/反序列化,这块有一些值得注意的优化点:
- 精简返回数据:只返回客户端真正需要的字段,别把整个数据库记录原样甩回去
- 用 FormData 而非大对象:表单提交的场景,直接用 FormData 比序列化一个大 JS 对象更高效
- 避免传不可序列化的值:Date 对象、Map、Set、类实例这些都没法通过 RSC 协议传输(踩过坑的人都知道)
8.2 错误处理策略
生产级应用的错误处理不能马虎。这里有一个比较通用的封装方式:
// lib/action-utils.ts
"use server"
type ActionResult<T> =
| { success: true; data: T }
| { success: false; error: string }
export function createAction<TInput, TOutput>(
handler: (input: TInput) => Promise<TOutput>
) {
return async (input: TInput): Promise<ActionResult<TOutput>> => {
try {
const data = await handler(input)
return { success: true, data }
} catch (error) {
// 在生产环境不暴露内部错误细节
if (error instanceof ActionError) {
return { success: false, error: error.message }
}
console.error("Unhandled action error:", error)
return { success: false, error: "操作失败,请稍后重试" }
}
}
}
export class ActionError extends Error {
constructor(message: string) {
super(message)
this.name = "ActionError"
}
}
8.3 调试 Server Actions
调试 Server Actions 有时候确实让人抓狂,因为它们横跨客户端和服务器两端。分享几个实用的调试技巧:
- console.log 看终端:Server Actions 里的
console.log输出在终端(服务器端),不是浏览器控制台。刚开始写的时候我盯着浏览器控制台看了半天,才想起来要看终端…… - 检查网络请求:浏览器开发者工具的 Network 面板,找 POST 请求,payload 里带着 Action ID
- React DevTools:最新版已经支持检查 Server Action 的状态和调用历史了
- 结构化日志:生产环境建议用 pino 之类的结构化日志库,记录每个 Action 的执行情况和耗时
第九章:迁移指南与常见问题
9.1 从 API Routes 迁移到 Server Actions
如果你现有项目用的是传统 API Routes,可以逐步迁移过来。不用一口气全改,挑新功能或者改动频繁的接口先迁,效果立竿见影。
// 迁移前:API Route + 客户端 fetch
// app/api/posts/route.ts
export async function POST(request: Request) {
const body = await request.json()
const post = await db.post.create({ data: body })
return Response.json(post)
}
// 客户端组件中
async function handleSubmit(data: PostData) {
const res = await fetch("/api/posts", {
method: "POST",
body: JSON.stringify(data)
})
const post = await res.json()
router.refresh()
}
// 迁移后:Server Action
// app/actions/post.ts
"use server"
export async function createPost(data: PostData) {
const post = await db.post.create({ data })
revalidatePath("/posts")
return post
}
// 客户端组件中直接调用
import { createPost } from "@/app/actions/post"
// 无需 fetch,无需 API route,直接调用
对比一下,代码量少了一大截,而且不用再操心请求序列化、错误响应格式这些事了。
9.2 常见问题解答
Q:Server Actions 能返回非序列化的数据吗?
A:不能。返回值必须是可序列化的。Date 对象会被自动转成字符串,但 Map、Set、类实例这些都不支持。日期建议统一用 ISO 字符串格式。
Q:Server Actions 有大小限制吗?
A:有的。Next.js 默认限制请求体为 1MB,但你可以在配置里调整:
// next.config.ts
const nextConfig = {
experimental: {
serverActions: {
bodySizeLimit: "10mb" // 调整为 10MB
}
}
}
Q:Server Actions 支持流式响应吗?
A:不支持。如果要流式传输(比如 AI 聊天场景),得用 Route Handlers 配合 ReadableStream。这也是为什么说 API Routes 不会被完全替代的原因。
Q:怎么对 Server Actions 做单元测试?
A:因为 Server Actions 本质上就是普通异步函数,测试方法跟测别的函数没什么两样。关键是 mock 好外部依赖:
// __tests__/actions/post.test.ts
import { createPost } from "@/app/actions/post"
jest.mock("@/lib/db")
jest.mock("next/cache", () => ({
revalidatePath: jest.fn()
}))
describe("createPost", () => {
it("应该创建文章并重验证缓存", async () => {
const formData = new FormData()
formData.set("title", "测试文章")
formData.set("content", "测试内容...")
const result = await createPost(formData)
expect(result.success).toBe(true)
expect(revalidatePath).toHaveBeenCalledWith("/posts")
})
})
总结
Server Actions 代表了全栈 React 开发的一个重要转折点。它打破了前后端之间那道人为的墙,让开发者能用更直观、更安全的方式处理数据变更。
回顾一下这篇文章的核心要点:
- 原理清晰:Server Actions 通过 POST 请求和 RSC 协议在服务器上执行,序列化和安全性都是自动处理的
- 类型安全:Zod + next-safe-action 的组合拳,端到端类型安全和输入验证一步到位
- 用户体验:useActionState、useOptimistic 这些 React 19 钩子,让渐进增强和即时反馈变得很容易实现
- 缓存管理:合理搭配 revalidatePath、revalidateTag 和 updateTag,数据一致性不再是头疼的问题
- 安全第一:React2Shell 事件告诉我们,哪怕是框架级的安全机制也可能有洞,保持依赖更新真的不是说说而已
随着 Next.js 和 React 生态的持续演进,Server Actions 在 2026 年只会变得更成熟更强大。如果你还没在项目里用起来,现在正是最好的时机。去试试吧,你会发现全栈开发可以比你想象的更简单。