next 精选推荐

Server Actions 完整指南

HTMLPAGE 团队
20 分钟阅读

从工作原理到安全边界与缓存语义,系统讲透 Next.js Server Actions 的编程模型,并给出可直接落地的工程化实践范式。

#Next.js #Server Actions #App Router #表单 #安全 #缓存

Server Actions 完整指南

Server Actions 是 Next.js(App Router 体系)里最有“范式转移”意味的能力之一:它把“从浏览器提交数据 → 走 API → 服务端写库 → 客户端再拉取数据”这条链路,压缩成在 UI 里直接调用一个服务端函数

但它并不是“把后端写进前端”,而是一套围绕 RSC(React Server Components)边界、请求序列化、缓存与失效、安全隔离 重新定义的交互协议。

这篇文章按“能在生产落地”为标准,依次讲清:

  • 什么时候该用/不该用 Server Actions
  • 'use server' 的语义与编译期边界
  • 表单提交、并发与幂等
  • 权限、校验、CSRF/重放、敏感信息
  • 缓存与 revalidate 的正确打开方式
  • 一套可复用的工程化结构(Action 层、schema、错误码)

1. Server Actions 解决的到底是什么问题?

在传统前端架构里,“写操作”通常需要两套代码:

  1. UI 层构造请求(fetch/axios)
  2. API 层解析请求、校验、执行业务

这导致三个常见痛点:

  • 样板代码多:DTO、路由、controller、错误映射
  • 类型不一致:前后端 schema 漂移,靠约定或手写 types
  • 缓存/失效复杂:写完数据后,页面该怎么更新?哪些缓存该失效?

Server Actions 的核心价值是:

  • 把“写操作”收敛成一个服务端函数,由框架负责把调用从 client 路由到 server;
  • 在 App Router 的缓存语义下,提供与页面缓存/请求缓存更一致的“失效”手段。

你可以把它理解为:Next.js 给“写操作”提供了一个与 RSC/缓存模型原生兼容的调用入口。


2. 心智模型:Server Actions 与 RSC 的边界

2.1 'use server' 不是运行时开关,而是编译期边界

在 Next.js 中:

  • 'use client' / 'use server'模块级/函数级的编译指令
  • 被标记为 Server Action 的函数只能在服务端执行;在客户端调用时,框架会生成一个“可调用的引用”,最终由服务端执行。

一个直观的理解:

  • 你写的是函数
  • 框架生成的是 RPC(带序列化、鉴权/上下文、执行、返回)。

2.2 传参限制:可序列化是第一原则

Server Action 的参数需要能安全序列化。

建议遵循:

  • 只传 primitive / plain object / array / FormData
  • 不传 class 实例、函数、DOM 节点、复杂原型对象

如果你发现自己想把“整个业务对象”丢进去,通常意味着你应该传一个 idpayload,然后在服务端再查询/校验。


3. 两种主流用法:表单 Action 与事件 Action

3.1 表单提交(推荐):天然 CSRF 友好,契合 Web 标准

Server Actions 最推荐的入口是表单:

// app/settings/page.tsx
import { updateProfileAction } from './actions'

export default function SettingsPage() {
  return (
    <form action={updateProfileAction}>
      <input name="displayName" />
      <button type="submit">保存</button>
    </form>
  )
}

在服务端:

// app/settings/actions.ts
'use server'

export async function updateProfileAction(formData: FormData) {
  const displayName = String(formData.get('displayName') || '').trim()
  // 校验、鉴权、写库...
}

为什么推荐表单?

  • 浏览器原生语义:回退/刷新更自然
  • 你更容易把“输入 → 校验 → 保存”做成明确的流水线
  • 比“按钮 onClick 调 action”更不容易写出隐式并发 bug

3.2 事件触发(慎用):更像 RPC,必须处理并发与幂等

'use client'
import { toggleStarAction } from './actions'

export function StarButton({ id }: { id: string }) {
  return (
    <button
      onClick={async () => {
        await toggleStarAction(id)
      }}
    >
      Star
    </button>
  )
}

这类用法更像“客户端发起 RPC”,你需要格外注意:

  • 重复点击导致并发
  • 网络抖动导致重试
  • 乐观更新与最终一致

4. 工程化落地:Action 层应该长什么样?

一个能长期维护的结构,通常把“Action 的对外接口”与“业务实现”分离:

  • actions/*.ts:只负责输入解析、校验、鉴权、错误映射、触发失效
  • services/*.ts:纯业务逻辑(可被 API/任务队列复用)
  • schemas/*.ts:输入 schema(建议用 zod 或你们统一的校验工具)

示例:

// app/settings/schemas.ts
import { z } from 'zod'

export const updateProfileSchema = z.object({
  displayName: z.string().min(2).max(32),
})
// app/settings/services.ts
export async function updateProfile(userId: string, input: { displayName: string }) {
  // 写库、写缓存、发事件...
}
// app/settings/actions.ts
'use server'

import { updateProfileSchema } from './schemas'
import { updateProfile } from './services'
import { revalidatePath } from 'next/cache'

class ActionError extends Error {
  code: string
  constructor(code: string, message: string) {
    super(message)
    this.code = code
  }
}

function requireUserId(): string {
  // 伪代码:从会话中拿 user
  // const user = await auth()
  // if (!user) throw new ActionError('UNAUTHORIZED', '请先登录')
  return 'user_123'
}

export async function updateProfileAction(formData: FormData) {
  const userId = requireUserId()

  const input = {
    displayName: String(formData.get('displayName') || '').trim(),
  }

  const parsed = updateProfileSchema.safeParse(input)
  if (!parsed.success) {
    throw new ActionError('VALIDATION_ERROR', '输入不合法')
  }

  await updateProfile(userId, parsed.data)

  // 关键:写操作后做缓存失效
  revalidatePath('/settings')
}

这个结构的好处:

  • Action 层是“协议适配器”,服务层是“领域逻辑”
  • 输入校验与鉴权在入口处统一完成
  • 缓存失效在入口处统一声明

5. 安全边界:你必须明确回答的 5 个问题

Server Actions 容易让人产生错觉:“既然在 server 执行,那就是安全的”。

更准确的说法是:Server Actions 让调用路径更短,但安全问题一个都不会消失

5.1 认证:Action 里必须做鉴权

不要假设“只有页面能调用它”。

  • 攻击者可以构造请求直接触发 action
  • 也可能通过 XSS / 依赖污染从客户端触发

结论:每个会修改数据的 action,都要在服务端检查身份与权限。

5.2 输入校验:永远不要信任 FormData

  • 字段缺失、空字符串、超长、类型错
  • 业务约束(例如“昵称不得包含敏感词”)

建议:用 schema 校验(zod)或你们统一的 validator。

5.3 幂等与重放:写操作要能承受重复执行

浏览器/网络层可能导致重复提交。

常见策略:

  • 业务幂等键:idempotencyKey
  • 数据层唯一约束:例如 (userId, itemId) 唯一
  • 事务:确保写入一致

5.4 CSRF:优先走 form action,并启用 same-site 策略

Server Actions 不等于“自动免疫 CSRF”。

  • 如果你依赖 cookie 会话,仍要考虑跨站请求
  • form action 的默认行为更符合浏览器标准,但你仍需要正确配置 cookie 的 SameSite 和 token

5.5 错误回传:不要把内部错误直接暴露给用户

Action 抛出的错误最终会影响 UI。

建议:

  • 对用户展示:可理解的、稳定的错误码/文案
  • 对服务端记录:完整 stack、请求上下文、userId、traceId

6. 缓存语义:写操作之后,页面为什么不更新?

在 App Router 模型里,“看起来像 SSR 的页面”实际上可能被缓存。

你需要掌握 3 个概念:

  • 请求缓存(fetch cache)
  • 页面/路由段缓存(RSC payload cache)
  • 失效机制(revalidatePath / revalidateTag)

6.1 写操作后,推荐怎么做?

  • 更新某个页面:revalidatePath('/xxx')
  • 更新多个数据源:给 fetch 打 tag,然后 revalidateTag('tag-name')

建议的经验法则:

  • 页面级别更新不多:优先 revalidatePath
  • 数据在多个页面复用:优先 revalidateTag

6.2 不要滥用 revalidatePath('/')

这会让你的缓存命中率断崖式下降。

更好的做法:

  • 只失效受影响的 route segment
  • 用 tag 精准失效

7. 并发与用户体验:让 Action 看起来“快而可靠”

7.1 useTransition 与 Pending 状态

当 action 触发后,用户需要看到明确反馈。

  • 按钮 loading
  • 禁用重复提交
  • 成功/失败 toast

7.2 乐观更新(谨慎)

乐观更新适合:

  • 可快速回滚
  • 失败概率低
  • 用户对即时反馈敏感(点赞/收藏)

对“资金/权限/关键数据”写操作不建议乐观更新。


8. 什么时候不该用 Server Actions?

  • 需要对外开放的公共 API(给第三方/移动端)
  • 需要长时间运行的任务(应改为队列/异步任务)
  • 需要复杂流控/网关策略(限流、WAF、跨服务认证)

Server Actions 是“Web 应用内的写操作入口”,不是 API 网关替代品。


9. 生产级检查清单

在把某个写操作迁移到 Server Actions 前,按这份清单过一遍:

  • action 是否做了鉴权与权限校验
  • 输入是否做了 schema 校验
  • 是否具备幂等策略(或数据层唯一约束)
  • 是否定义了稳定的错误码/文案
  • 写操作后是否做了精确的缓存失效(path/tag)
  • 是否有可观测性:日志、traceId、错误上报

10. 总结

Server Actions 的正确价值不是“少写 API”,而是:

  • 把写操作纳入 App Router/RSC 的统一模型
  • 让缓存失效与 UI 刷新更一致
  • 在正确工程结构下,显著减少样板代码与类型漂移