Next.js Server Actions 深度
Server Actions 让你在组件里直接写服务端函数,前端调它像调 async function——不用写 API 路由。本文讲清 Server Actions 的工作原理、'use server' 的含义、表单提交模式(渐进增强)、revalidate 重验证、安全边界(它是公开端点!),以及和传统 API 路由的取舍。
本文要点
- 主题聚焦:Next.js、React
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
写 Next 应用,有一类活儿特别烦:创建一篇文档、改个用户资料、删条评论。逻辑跑在服务端(要查库、要鉴权),可触发点在前端。传统做法是写一个 /api/posts/route.ts,前端 fetch 它,自己处理序列化、错误、loading、CSRF token——一个简单的"提交表单"硬生生拆成两端两套代码。
Server Actions 把这事揉回了一个人能维护的样子:在组件里直接写一个服务端函数,前端调它就像调一个 async function。不用建路由文件,不用手写 fetch。本文把它从原理到坑讲透。
一、它到底解决了什么
一句话:让"前端触发服务端副作用"这件事,代码组织上和"前端调一个本地函数"一样。
你写一个标了 'use server' 的 async 函数,它就只能在服务端跑。前端拿到的是它的一个"引用"——调用时,Next 帮你发请求到服务端执行,把结果返回。鉴权、查库、写库、读环境变量,都在这个函数里干,密钥永远不会进前端 bundle。
注意术语:React 官方叫 Server Function;当它被用在表单 action、按钮 formAction 这类"变更(mutate)"场景时,Next 称之为 Server Action。本质同一个东西,本文按 Next 的习惯混用。
二、'use server' 背后:编译成一个 RPC 端点
'use server' 是一个编译器指令,不是运行时函数。它有两种写法:
// 写法 A:整个文件都是服务端函数
'use server'
import { db } from '@/lib/db'
export async function createPost(formData: FormData) {
await db.post.create({ data: { title: String(formData.get('title')) } })
}
// 写法 B:内联在某个函数体顶部
export default function Page() {
async function publish(formData: FormData) {
'use server'
// ...
}
return <form action={publish}><button>发布</button></form>
}
编译期,Next 给每个 action 生成一个加密的、非确定性的 action ID。前端拿到的不是函数本体,而是一个"action ID 引用"。你调用它时,框架做的事是:序列化参数 → POST 到这个 action ID 对应的内部端点 → 服务端反序列化、执行 → 把返回值序列化回来。
如果用 Object.fromEntries(formData) 去读表单,你会看到几个以 $ACTION_ 开头的额外字段——那就是 Next 注入的 action ID 和元信息,框架靠它们把这次 POST 路由到正确的服务端函数。日常开发用 formData.get('字段名') 取值,不必关心这些内部字段。
几个值得知道的实现细节:
- action ID 每次构建重新生成,最多缓存 14 天。换 build,旧 ID 就废了——这也是一种轻量的防猜测。
- 没有被任何组件引用的 action,会被 dead code elimination 从产物里删掉,不会留下公开端点。
- 客户端调用 action 走的是
POST,而且只接受POST。
所以它本质上就是 RPC,只不过这个 RPC 是编译器帮你织出来的,你只管写函数签名。
三、表单模式:action={fn} + FormData,天然渐进增强
Server Action 真正顺手的场景是表单。React 给 <form> 扩展了 action 属性,直接传一个 Server Action:
import { createPost } from '@/app/actions'
export function NewPost() {
return (
<form action={createPost}>
<input name="title" required />
<textarea name="content" />
<button type="submit">发布</button>
</form>
)
}
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
export async function createPost(formData: FormData) {
const title = String(formData.get('title'))
const content = String(formData.get('content'))
await db.post.create({ data: { title, content } })
revalidatePath('/posts')
}
注意函数签名:第二个参数是原生 FormData,用 formData.get('name') 取值,字段名对应 input 的 name。不要用 JSON——FormData 就是它的"母语"。
最妙的一点:这是渐进增强的。当这个 form 出现在 Server Component 里、用户禁用了 JS 或 JS 还没加载完,点提交照样能工作——浏览器用原生表单 POST 把 action ID 当 endpoint 提交掉,服务端照常处理。在客户端组件里,JS 没加载时会先排队,hydration 后才提交。这意味着首屏交互不依赖 hydration,体验稳得多。
要往 action 里多塞个参数(比如 postId),用 bind 预设,而不是走闭包:
const updateWithId = updatePost.bind(null, postId)
return <form action={updateWithId}>...</form>
bind 同样支持渐进增强,而走闭包会带来后面要讲的坑。
四、useActionState:在客户端接管状态与 loading
原生 form 提交不给你 loading、不给你返回值。要这些,把组件改成客户端组件,用 React 的 useActionState:
'use client'
import { useActionState } from 'react'
import { signup } from '@/app/actions'
export function SignupForm() {
const [state, formAction, pending] = useActionState(signup, { errors: undefined })
return (
<form action={formAction}>
<input name="email" type="email" required />
{state.errors?.email && <p className="text-red-600">{state.errors.email}</p>}
<button disabled={pending}>{pending ? '提交中…' : '注册'}</button>
</form>
)
}
对应的 action 签名要多一个 prevState 作为第一个参数:
'use server'
import { z } from 'zod'
const schema = z.object({ email: z.string().email() })
export async function signup(prevState: any, formData: FormData) {
const parsed = schema.safeParse({ email: formData.get('email') })
if (!parsed.success) {
return { errors: parsed.error.flatten().fieldErrors }
}
// 写库...
return { errors: undefined, ok: true }
}
useActionState 返回的 formAction 依然可以直接塞进 <form action={...}>——渐进增强没破,JS 没加载时退化成原生提交,加载后由 hook 接管。要单独控制按钮的 loading 而不想提升整组件为 client,把按钮拆成子组件,用 useFormStatus()(它读的是最近的 <form> 上下文)。需要"乐观更新",配 useOptimistic。
这套组合拳的设计意图很清楚:有 JS 走 JS 的最佳体验,没 JS 走原生表单兜底。两条路同一个 action,业务逻辑只写一遍。
五、revalidatePath / revalidateTag:执行完刷新缓存
App Router 会把页面、fetch 的结果缓存起来。你写完库之后如果不主动失效,用户看到的还是旧数据。在 action 里调用:
import { revalidatePath, revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) {
await db.post.create({ /* ... */ })
revalidatePath('/posts') // 按路径失效
revalidateTag('posts') // 按 tag 失效(fetch 时用 next: { tags: ['posts'] } 标的)
}
revalidatePath('/posts'):失效这个路径相关的缓存(包括它的布局链)。revalidateTag('posts'):失效所有打了这个 tag 的fetch/'use cache'块。适合一个数据源被多个页面共用的情况。
一次 mutation 之后想跳走,用 redirect('/posts')——注意它靠抛异常实现跳转,它后面的代码不会执行,所以 revalidatePath 要写在 redirect 之前。
有个新成员 refresh()(从 next/cache 导入)可以刷新当前路由树,但它不会失效 tag 化的数据,要刷 tag 数据还得 revalidateTag。
六、安全边界(关键):它就是个公开 HTTP 端点
这一节是新手最容易栽的地方。Server Action 是一个对外的 HTTP POST 端点,任何人都能直接 POST 调用它——不是必须通过你的 UI。哪怕这个 action 只在某个登录后的页面里出现过,用户也能拿到 action ID 后脱离页面直接打。
所以两条铁律:
- 每个 action 里都要重新鉴权。页面级的登录检查保护不了里面的 action,因为 action 是独立入口。
- 校验输入 + 做授权(authorization),不只是认证(authentication)。别只查"登录没",要查"这个用户对这条资源有没有权限"——否则就是经典的 IDOR(越权)漏洞。
'use server'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
export async function deletePost(postId: string) {
const session = await auth()
if (!session?.user) throw new Error('Unauthorized')
const post = await db.post.findUnique({ where: { id: postId } })
if (!post || post.authorId !== session.user.id) {
throw new Error('Forbidden') // 不是你的帖子,删不了
}
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
}
框架层面的内建防护:
- action ID 加密且每次构建变化,降低被枚举的风险,但这不能当鉴权用。
- CSRF 防护:action 只接受
POST,并会比对请求的Origin头和Host(或X-Forwarded-Host)是否同源,不匹配直接拒。配合现代浏览器默认的SameSitecookie,绝大多数 CSRF 被挡掉。如果你走反代、域名和后端不同,在next.config里配experimental.serverActions.allowedOrigins。 - body 大小限制:默认 1MB,防大 payload 打爆服务端。需要更大时配
experimental.serverActions.bodySizeLimit: '2mb'。 - 返回值会被序列化发给前端,只返回 UI 需要的最小字段,别把整条数据库记录丢回去。
一句话:把 Server Action 当成和 /api/route.ts 一样公开、一样需要防守的东西写。
还有一条容易被忽略:贵的操作要加限流。发邮件、写库、调 LLM 这类按次/按量计费的动作,action 本身不会帮你防刷。按用户或 IP 加一层限流(upstash/ratelimit、中间件都行),和你在 API 路由里会做的一样。
七、和 API 路由的取舍
什么时候用 Server Action,什么时候老老实实写 Route Handler(/api/.../route.ts)?
Server Action 适合:跟某个 UI/组件强绑定的服务端逻辑——表单提交、行内点赞、删除按钮、设置开关。它和组件耦合、消费的是组件上下文(闭包、bind、当前路由),不需要被外部世界发现。
API 路由适合:对外提供的公开 API、接收第三方 Webhook、给非浏览器客户端(cURL、移动端、定时任务、第三方服务)调用的入口。这类调用没有"渲染它的组件"可言,需要一个稳定、文档化、可独立版本管理的 URL。
判断标准很简单:**这个服务端入口有没有"组件上下文"?**有,用 action;没有,用 route。两者不互斥,中型项目里同时存在很正常。
八、四个会咬人的坑
-
闭包传值要小心。把 action 内联在组件里时,它会捕获外层变量(比如
const version = await getLatestVersion()),这些值会被序列化发到前端、再随调用发回服务端(Next 会加密,密钥每次构建变)。可以用来给 action 传"渲染时刻的快照",但绝不要靠它传敏感数据——加密只是兜底,不是授权。多实例自托管时,各服务器加密密钥不一致会让 action 失效,这时设NEXT_SERVER_ACTIONS_ENCRYPTION_KEY环境变量统一密钥。函数、类实例根本传不了,框架默认拦死。 -
大 payload 走 FormData,别走 JSON 参数。传几个字段就用 FormData;非要传嵌套大对象,默认 1MB 上限会拦你,而且序列化开销不小。真要传文件, FormData 配合
bodySizeLimit调大。 -
别在渲染阶段副作用。在 Server Component 渲染函数里直接改库、删 cookie、revalidate,是反模式——Next 会显式阻止在渲染期 revalidate 或改 cookie。所有变更都走 action。
-
action 是串行派发的。当前实现里,客户端对 action 的派发是逐个 await、一次一个,别指望并发跑多个 action。要并行取数请在 Server Component 里并发
fetch,要并行变更就在单个 action 内部Promise.all,或者干脆写个 Route Handler 自己控制。
小结
- Server Action = 标了
'use server'的服务端 async 函数,编译成加密 ID 的 RPC 端点,前端像调本地函数一样调它,免写/api路由。 - 表单
action={fn}+FormData是它的主场,Server Component 里天然渐进增强;要 loading/返回值,客户端组件配useActionState,formAction仍可塞进<form action>,退化照常工作。 - 写完库用
revalidatePath/revalidateTag失效缓存;redirect用抛异常实现,放最后。 - 安全红线:它是公开 POST 端点,每个 action 内重新鉴权 + 输入校验 + 资源级授权,框架的 action ID 加密和 Origin 校验只是辅助。
- 取舍:组件强耦合的逻辑用 action,对外 API/Webhook/非浏览器调用用 Route Handler。
一句话收尾:Server Action 让"前端触发服务端变更"的代码结构,终于跟得上它的实际复杂度——但端点的公开属性没变,该守的安全一点都不能少。
Related