#Next.js#React

Next.js Server Actions 深度

Server Actions 让你在组件里直接写服务端函数,前端调它像调 async function——不用写 API 路由。本文讲清 Server Actions 的工作原理、'use server' 的含义、表单提交模式(渐进增强)、revalidate 重验证、安全边界(它是公开端点!),以及和传统 API 路由的取舍。

本文要点

  • 主题聚焦:Next.js、React
  • 适合人群:独立开发者、全栈工程师、AI 应用开发者
  • 核心价值:快速理解背景、实践路径、常见误区和工程建议
作者 @admin·发布于 2026年4月20日·更新于 2026年7月23日

写 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' 是一个编译器指令,不是运行时函数。它有两种写法:

ts
// 写法 A:整个文件都是服务端函数
'use server'
import { db } from '@/lib/db'

export async function createPost(formData: FormData) {
  await db.post.create({ data: { title: String(formData.get('title')) } })
}
tsx
// 写法 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:

tsx
import { createPost } from '@/app/actions'

export function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" />
      <button type="submit">发布</button>
    </form>
  )
}
ts
// 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 预设,而不是走闭包:

tsx
const updateWithId = updatePost.bind(null, postId)
return <form action={updateWithId}>...</form>

bind 同样支持渐进增强,而走闭包会带来后面要讲的坑。

四、useActionState:在客户端接管状态与 loading

原生 form 提交不给你 loading、不给你返回值。要这些,把组件改成客户端组件,用 React 的 useActionState:

tsx
'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 作为第一个参数:

ts
'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 里调用:

ts
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 后脱离页面直接打。

所以两条铁律:

  1. 每个 action 里都要重新鉴权。页面级的登录检查保护不了里面的 action,因为 action 是独立入口。
  2. 校验输入 + 做授权(authorization),不只是认证(authentication)。别只查"登录没",要查"这个用户对这条资源有没有权限"——否则就是经典的 IDOR(越权)漏洞。
ts
'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)是否同源,不匹配直接拒。配合现代浏览器默认的 SameSite cookie,绝大多数 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。两者不互斥,中型项目里同时存在很正常。

八、四个会咬人的坑

  1. 闭包传值要小心。把 action 内联在组件里时,它会捕获外层变量(比如 const version = await getLatestVersion()),这些值会被序列化发到前端、再随调用发回服务端(Next 会加密,密钥每次构建变)。可以用来给 action 传"渲染时刻的快照",但绝不要靠它传敏感数据——加密只是兜底,不是授权。多实例自托管时,各服务器加密密钥不一致会让 action 失效,这时设 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 环境变量统一密钥。函数、类实例根本传不了,框架默认拦死。

  2. 大 payload 走 FormData,别走 JSON 参数。传几个字段就用 FormData;非要传嵌套大对象,默认 1MB 上限会拦你,而且序列化开销不小。真要传文件, FormData 配合 bodySizeLimit 调大。

  3. 别在渲染阶段副作用。在 Server Component 渲染函数里直接改库、删 cookie、revalidate,是反模式——Next 会显式阻止在渲染期 revalidate 或改 cookie。所有变更都走 action。

  4. 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

相关推荐