React 基础体系 · 第 67/70 篇。示例以 React 19、现代 TypeScript 和主流框架能力为基础;客户端与服务端边界会明确说明。

Next.js Server Actions:序列化、认证、校验、重放和渐进增强

Server Actions 是一种让客户端组件以“调用函数”的形式触发服务端代码的机制。它不是把普通 JavaScript 函数透明地搬到服务器上,而是将函数注册为服务端入口,客户端提交可序列化的参数,框架在服务器执行函数,再把可序列化的结果或错误状态返回给客户端。

因此,一个 Server Action 同时具有三种属性:

  1. 它是服务端函数,可以访问数据库、密钥和服务器运行时。
  2. 它是跨边界调用的入口,调用参数和返回值必须经过序列化。
  3. 它通常通过 HTTP POST 暴露,不能因为“源码写在服务端”就忽略认证、授权、输入校验和重复请求。

本文以 React 19、现代 TypeScript 和 Next.js App Router 为范围,重点说明这条边界上的五个问题:

  • 什么能够穿过客户端与服务器边界;
  • 如何在 Action 内认证用户并授权操作;
  • 为什么 TypeScript 类型不能代替运行时校验;
  • 为什么请求可能被重放,以及如何实现幂等;
  • 没有 JavaScript 或尚未完成 Hydration 时,表单如何继续工作。

一、Server Action 的真实调用模型

1. use server 的含义

在服务端文件顶部写:

// app/actions.ts
'use server'

export async function createProject(formData: FormData) {
  // 这里的代码在服务端执行
}

'use server' 是指令。对文件使用时,该文件导出的函数都可以作为 Server Function;对函数使用时,只标记单个函数。

客户端拿到的并不是 createProject 的函数体,而是一个可以被 React/Next.js 识别的服务端引用。调用过程可以抽象为:

客户端组件
  │
  │  提交参数:FormData / 字符串 / 普通对象
  ▼
React Server Components 协议与 Next.js Action 入口
  │
  │  服务器反序列化参数
  ▼
Server Action
  │
  ├─ 认证
  ├─ 授权
  ├─ 输入校验
  ├─ 幂等检查
  └─ 数据库写入
  │
  ▼
序列化返回值或错误
  │
  ▼
客户端更新状态或执行重定向

这里的“调用”不是普通的本地函数调用。网络延迟、连接中断、浏览器重复提交、服务器重试和用户恶意构造请求都可能发生。

2. Server Action 通常是 POST 入口

Server Actions 主要用于变更数据,例如创建、修改、删除记录。Next.js 会为 Action 生成服务端调用入口,表单提交和程序化调用最终都需要向服务器发送请求。

这意味着下面的判断是错误的:

“这个函数只被我的按钮调用,所以不需要权限检查。”

正确的判断是:

“这个函数是一个服务端入口,任何到达该入口的请求都必须重新认证、授权和校验。”

客户端按钮、菜单隐藏、路由保护只能改善用户体验,不能构成安全边界。


二、序列化:跨边界传输的到底是什么

1. 序列化的定义

序列化是把内存中的值转换为可以跨进程、跨网络传输的表示;反序列化则是服务器把这种表示恢复为可使用的值。

Server Action 的参数和返回值需要符合 React Server Components 所支持的可序列化范围。工程上最稳妥的公共数据结构是:

  • string
  • number
  • boolean
  • null
  • undefined
  • 数组
  • 只包含上述值的普通对象
  • 表单提交产生的 FormData

某些 React 版本还支持特定内建类型,例如 DateMapSetURLFormDataFile 等,但具体支持范围应以当前 React 和 Next.js 版本文档为准。不要把“JavaScript 能保存”误认为“能够穿过 RSC/Action 协议”。

以下值不应作为 Action 参数或返回值设计的一部分:

// 不要这样设计
export async function badAction(
  db: DatabaseClient,
  callback: () => void,
  request: Request,
) {
  // ...
}

原因分别是:

  • 数据库客户端包含连接、缓存和方法,不能序列化;
  • 函数不能被安全地编码为远程调用参数;
  • Request 不是普通业务数据,且其流状态不能简单跨边界传递。

应当传递业务标识和普通数据,在服务端重新获取资源:

'use server'

export async function updateProject(
  projectId: string,
  input: { name: string },
) {
  const project = await db.project.findUnique({
    where: { id: projectId },
  })

  if (!project) {
    return { ok: false, message: '项目不存在' }
  }

  // 使用服务器上的 db,而不是从客户端传入 db
  await db.project.update({
    where: { id: projectId },
    data: { name: input.name },
  })

  return { ok: true }
}

2. FormData 的特点

HTML 表单提交的数据不是一个已经按 TypeScript 类型组织好的对象,而是键值集合:

<input name="name" value="demo">
<input name="count" value="3">

服务端收到后:

const name = formData.get('name')
const count = formData.get('count')

得到的值可能是:

  • 字符串;
  • File
  • null
  • 对于重复字段,同名字段还可能有多个值。

即使 HTML 中有:

<input name="count" type="number">

浏览器提交的仍然通常是字符串 "3",不是数字 3type="number"主要影响浏览器界面和部分客户端校验,不会让服务端获得 TypeScript 的 number

3. 返回值应当是显式 DTO

建议返回简单的状态对象,而不是直接返回数据库实体、ORM 对象或 Error

type ActionState = {
  ok: boolean
  message?: string
  fieldErrors?: Record<string, string[]>
}

原因有两个:

  1. ORM 实例可能包含不可序列化的原型、方法或特殊字段。
  2. 返回 DTO 能够明确区分用户可见错误和内部异常。

例如:

return {
  ok: false,
  message: '项目名称已存在',
  fieldErrors: {
    name: ['请使用其他名称'],
  },
}

序列化不是加密。任何被返回给客户端的字段都应视为客户端可见数据;数据库内部字段、权限信息和密钥不能因为“经过了 RSC 协议”就变得保密。


三、一个完整的表单 Action

下面使用 zod 做运行时校验。安装依赖:

npm install zod

1. 服务端 Action

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

import { z } from 'zod'
import { redirect } from 'next/navigation'
import { requireUser } from '@/lib/auth'
import { db } from '@/lib/db'

const createProjectSchema = z.object({
  name: z
    .string()
    .trim()
    .min(1, '项目名称不能为空')
    .max(100, '项目名称不能超过 100 个字符'),
})

export type CreateProjectState = {
  ok: boolean
  message?: string
  fieldErrors?: {
    name?: string[]
  }
}

export async function createProject(
  _previousState: CreateProjectState,
  formData: FormData,
): Promise<CreateProjectState> {
  const user = await requireUser()

  const rawName = formData.get('name')

  // 先确认字段形状,再交给 schema 处理
  if (typeof rawName !== 'string') {
    return {
      ok: false,
      message: '请求格式错误',
      fieldErrors: {
        name: ['name 必须是文本'],
      },
    }
  }

  const parsed = createProjectSchema.safeParse({
    name: rawName,
  })

  if (!parsed.success) {
    return {
      ok: false,
      message: '请修正表单错误',
      fieldErrors: parsed.error.flatten().fieldErrors,
    }
  }

  const existing = await db.project.findFirst({
    where: {
      ownerId: user.id,
      name: parsed.data.name,
    },
    select: { id: true },
  })

  if (existing) {
    return {
      ok: false,
      message: '你已经有同名项目',
      fieldErrors: {
        name: ['项目名称必须唯一'],
      },
    }
  }

  const project = await db.project.create({
    data: {
      ownerId: user.id,
      name: parsed.data.name,
    },
    select: { id: true },
  })

  // redirect 会中断当前函数,不应再依赖它后面的代码执行
  redirect(`/projects/${project.id}`)
}

这个 Action 的顺序不是随意的:

  1. requireUser() 确认调用者身份;
  2. FormData 取出原始值;
  3. 运行时校验值的形状和业务约束;
  4. 使用当前用户身份执行查询;
  5. 创建记录;
  6. 成功后重定向。

其中 requireUserdb 是应用自己的基础设施。一个典型的 requireUser 应当在服务端读取会话 Cookie 或访问认证系统,并在没有有效身份时抛出未认证错误或重定向到登录页。不能从表单中接收 userId 后直接相信它。

2. 客户端表单

// app/projects/create-project-form.tsx
'use client'

import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import {
  createProject,
  type CreateProjectState,
} from './actions'

const initialState: CreateProjectState = {
  ok: false,
}

function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? '创建中…' : '创建项目'}
    </button>
  )
}

export function CreateProjectForm() {
  const [state, formAction] = useActionState(
    createProject,
    initialState,
  )

  return (
    <form action={formAction}>
      <label>
        项目名称
        <input name="name" required maxLength={100} />
      </label>

      {state.fieldErrors?.name?.map((error) => (
        <p key={error} role="alert">
          {error}
        </p>
      ))}

      {state.message && !state.fieldErrors?.name && (
        <p role="status">{state.message}</p>
      )}

      <SubmitButton />
    </form>
  )
}

在 React 19 中,useActionState 的 Action 签名是:

(previousState, formData) => nextState

所以服务端函数的第一个参数不是表单,而是上一次状态。直接写成下面这样会导致参数错位:

// 错误示例:把 previousState 当成 FormData 使用
export async function wrongAction(formData: FormData) {
  const name = formData.get('name')
}

如果不使用 useActionState,而是直接写:

<form action={createProject}>

则 Action 可以采用只接收 FormData 的签名:

'use server'

export async function createProjectDirect(formData: FormData) {
  // ...
}

两种形式不能混用。useActionState 会为 Action 增加状态参数,并返回一个新的 formAction

useFormStatus 必须放在 <form> 的后代组件中。它读取最近表单的提交状态;把它放在表单外部,通常无法得到该表单的 pending 状态。


四、认证和授权不是同一件事

1. 认证:你是谁

认证(authentication)是确认调用者身份。例如从会话 Cookie 中得到:

type User = {
  id: string
  role: 'user' | 'admin'
}

requireUser() 应该在没有有效会话时阻止继续执行。

2. 授权:你能做什么

授权(authorization)是判断已经识别出的用户是否有权执行当前操作。即使用户已经登录,也不代表他能修改任意项目。

错误写法:

const project = await db.project.findUnique({
  where: { id: projectId },
})

if (!project) {
  throw new Error('not found')
}

await db.project.update({
  where: { id: projectId },
  data: { name },
})

这里没有确认项目属于当前用户。攻击者只要把 projectId 换成其他人的 ID,就可能越权修改。

正确做法是把权限条件放进查询或更新条件:

const result = await db.project.updateMany({
  where: {
    id: projectId,
    ownerId: user.id,
  },
  data: {
    name,
  },
})

if (result.count !== 1) {
  return {
    ok: false,
    message: '项目不存在,或你没有修改权限',
  }
}

这样数据库操作本身就带有所有权条件。读取和写入都要这样处理,而不是只在页面加载时检查一次。

3. 认证不能依赖客户端传值

以下字段都不能作为权限依据:

formData.get('userId')
formData.get('role')
formData.get('isAdmin')

客户端可以修改隐藏字段、构造新的 POST 请求,甚至完全绕过页面。正确关系应当是:

请求携带:projectId、name
服务器推导:userId、role、权限范围
数据库校验:projectId 是否属于 userId

角色和所有权必须来自服务端可信来源,例如签名会话、数据库或身份提供商。

4. CSRF 与来源检查

Server Actions 使用 POST 和 Cookie 会话时,仍然要考虑跨站请求伪造(CSRF)。现代浏览器的 SameSite Cookie 策略、Next.js 对 Action 请求来源的检查以及部署平台的代理配置可以降低风险,但它们不是业务授权的替代品。

需要特别检查:

  • 生产环境是否正确设置 HTTPS;
  • 会话 Cookie 是否设置 Secure、合适的 SameSiteHttpOnly
  • 反向代理是否正确转发 Host 和 Origin;
  • Next.js 版本与配置是否允许特定的额外来源;
  • 是否把 Action 错当成允许任意第三方调用的公开 API。

如果需要让跨站客户端正式调用,应设计独立的 API 认证和 CSRF 策略,而不是依赖 Server Action 的内部调用约定。


五、校验:TypeScript 不会校验网络输入

1. 类型注解在运行时不存在

下面的类型只在编译阶段帮助开发者:

type Input = {
  name: string
}

export async function action(input: Input) {
  // input 在运行时仍然可能是恶意构造的数据
}

请求到达服务器时,攻击者可以发送:

{
  "name": 123
}

或者直接提交没有 name 的表单。TypeScript 不会替服务器解析网络数据。

因此应区分两层:

  • 传输校验:字段是否存在、类型是否正确、是否超过大小;
  • 业务校验:名称是否为空、状态转换是否合法、用户是否有权限。

2. FormData 的重复字段和文件字段

不应对所有字段无条件使用 String()

// 风险:null 会变成 "null",File 也可能变成 "[object File]"
const name = String(formData.get('name'))

应先检查:

const value = formData.get('name')

if (typeof value !== 'string') {
  return {
    ok: false,
    message: 'name 必须是文本',
  }
}

如果字段允许多个值,应明确使用:

const tags = formData.getAll('tags')

if (!tags.every((tag): tag is string => typeof tag === 'string')) {
  return {
    ok: false,
    message: '标签格式错误',
  }
}

文件上传还要校验大小、MIME 类型、文件名和存储路径。浏览器提交的 MIME 类型不能单独作为文件内容真实性证明;高风险场景应在服务器检查文件头,并使用不可预测的存储名。

3. 数据库约束仍然必要

应用层检查不能取代数据库唯一约束。假设两个并发请求都执行:

请求 A:查询“名称不存在”
请求 B:查询“名称不存在”
请求 A:插入
请求 B:插入

如果只有应用层查询,两者都可能成功。应同时建立数据库约束:

CREATE UNIQUE INDEX project_owner_name_unique
ON projects (owner_id, name);

然后把唯一冲突转换成用户可理解的状态。应用层检查用于较早反馈,数据库约束用于最终一致性。


六、重放:同一个 Action 可能被执行多次

1. 重放不是只有攻击者才会造成

一次提交的真实状态可能是:

客户端发送请求
服务器完成数据库写入
服务器准备返回响应
网络连接断开
客户端不知道写入是否成功
用户再次点击提交

这时第二次请求可能合法地再次执行。其他来源还包括:

  • 用户双击提交按钮;
  • 浏览器或用户代理重试;
  • 移动网络切换;
  • 用户刷新或返回后重新提交;
  • 攻击者主动复制 POST 请求。

因此,任何“创建订单”“扣款”“发放奖励”“发送邮件”的 Action 都不能默认只执行一次。

2. 幂等的定义

如果同一个业务操作使用同一个幂等键 K 重复提交,系统应满足:

F(K,x)=F(K,x)F(K, x) = F(K, x)

这里:

  • K 是幂等键;
  • x 是请求内容;
  • F 是业务操作;
  • 重复调用应返回同一个业务结果,而不是重复产生副作用。

这不等于所有请求都天然幂等。例如:

await db.project.create({ data: ... })

每执行一次都会产生一条记录,它是非幂等操作。

而:

await db.project.update({
  where: { id },
  data: { name: 'new-name' },
})

在“设置为某个值”的语义下通常更接近幂等,但仍要考虑并发更新和审计事件是否会重复写入。

3. 幂等键必须由服务端参与约束

可以为业务操作建立收据表:

CREATE TABLE action_receipts (
  user_id TEXT NOT NULL,
  operation TEXT NOT NULL,
  idempotency_key TEXT NOT NULL,
  request_hash TEXT NOT NULL,
  result JSONB NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, operation, idempotency_key)
);

主键确保同一个用户、同一种操作、同一个幂等键只有一条收据。

表单中可以携带一个随机键:

<input
  type="hidden"
  name="idempotencyKey"
  value={idempotencyKey}
/>

这个键应在服务端渲染表单时生成,例如:

// Server Component
import { randomUUID } from 'node:crypto'
import { CreateProjectForm } from './create-project-form'

export default function Page() {
  return (
    <CreateProjectForm idempotencyKey={randomUUID()} />
  )
}

客户端组件接收并提交它:

export function CreateProjectForm({
  idempotencyKey,
}: {
  idempotencyKey: string
}) {
  // ...
  return (
    <form action={formAction}>
      <input type="hidden" name="idempotencyKey" value={idempotencyKey} />
      {/* 其他字段 */}
    </form>
  )
}

服务端不能只做“先查询收据,再插入业务记录”,因为并发请求可能同时查不到收据。正确的实现必须依靠数据库唯一约束和事务:

开始事务
  1. 尝试插入 action_receipts
  2. 如果键已存在:
       - request_hash 不同:拒绝,说明同一键被用于不同请求
       - request_hash 相同:返回已有 result
  3. 如果插入成功:
       - 执行业务写入
       - 把业务结果写入收据
提交事务

伪代码如下,实际 SQL/ORM 语法需按数据库实现:

await db.$transaction(async (tx) => {
  const previous = await tx.actionReceipt.findUnique({
    where: {
      userId_operation_idempotencyKey: {
        userId: user.id,
        operation: 'create-project',
        idempotencyKey,
      },
    },
  })

  if (previous) {
    if (previous.requestHash !== requestHash) {
      throw new Error('同一个幂等键对应了不同请求')
    }

    return JSON.parse(previous.result)
  }

  const project = await tx.project.create({
    data: {
      ownerId: user.id,
      name,
    },
  })

  const result = {
    ok: true,
    projectId: project.id,
  }

  await tx.actionReceipt.create({
    data: {
      userId: user.id,
      operation: 'create-project',
      idempotencyKey,
      requestHash,
      result: JSON.stringify(result),
    },
  })

  return result
})

这里的事务有两个关键作用:

  • 业务记录和幂等收据要么一起提交,要么一起回滚;
  • 并发请求由唯一约束裁决,而不是依赖应用代码的时间顺序。

如果副作用不能放进同一个数据库事务,例如调用支付服务或发送邮件,则需要使用支付方提供的幂等键,或者采用 Outbox 等可靠消息模式。仅在内存中保存幂等键,在多实例部署、进程重启或边缘运行时中都不可靠。

4. 幂等键不是授权令牌

幂等键只用于识别“同一次业务意图”,不能代替登录凭证或权限检查。收据查询必须至少绑定:

用户身份 + 操作类型 + 幂等键

否则一个用户可能拿到另一个用户的键并读取或复用对方的结果。


七、渐进增强:没有 JavaScript 也能提交

渐进增强(progressive enhancement)意味着:先保证基础 HTML 交互成立,再在 JavaScript 可用时增加 pending 状态、局部更新和更丰富的错误显示。

Server Actions 与表单结合时,React/Next.js 可以在客户端组件尚未完成 Hydration 前处理表单提交。用户不必等待整段客户端 JavaScript 下载完成才能提交表单。

其状态变化可以表示为:

stateDiagram-v2
    [*] --> Rendered
    Rendered --> Submitted: 用户提交表单
    Submitted --> ServerValidating: 服务器收到 POST
    ServerValidating --> ErrorState: 认证/校验/业务失败
    ServerValidating --> Success: 写入成功
    Success --> Redirected: redirect()
    ErrorState --> Rendered: 返回表单状态
    Rendered --> Hydrated: 客户端完成 Hydration
    Hydrated --> Pending: useFormStatus()
    Pending --> ErrorState: Action 返回错误状态
    Pending --> Redirected: Action 重定向

关键路径是:

  1. 服务端先输出包含 <form><input> 的 HTML;
  2. 用户提交后,浏览器仍然可以发送表单;
  3. 服务器执行 Server Action;
  4. 成功时可以重定向;
  5. 失败时返回结构化状态;
  6. Hydration 完成后,React 接管表单,使 useActionStateuseFormStatus 提供更细的交互反馈。

1. 哪些能力依赖 JavaScript

以下能力通常需要客户端 JavaScript 已经运行:

  • pending 按钮状态;
  • 不刷新页面地更新错误信息;
  • 客户端字段联动;
  • 提交前的即时校验;
  • 防止按钮重复点击的本地逻辑。

以下能力不应只依赖 JavaScript:

  • 用户身份判断;
  • 权限判断;
  • 服务端输入校验;
  • 数据库唯一性;
  • 幂等控制;
  • 最终业务写入。

disabled={pending} 可以减少正常用户双击,但不能解决恶意重放,也不能覆盖“服务器已写入、客户端未收到响应”的情况。真正的重复保护仍然要在服务端完成。

2. 表单失败和异常失败

用户输入错误是预期分支,适合返回状态:

return {
  ok: false,
  message: '请修正表单错误',
  fieldErrors: {
    name: ['名称过短'],
  },
}

数据库宕机、代码 Bug、第三方服务超时则属于非预期异常。可以记录服务器日志并返回通用错误,不应把堆栈、SQL、内部路径返回给客户端:

try {
  // 业务操作
} catch (error) {
  console.error('create project failed', error)

  return {
    ok: false,
    message: '服务暂时不可用,请稍后重试',
  }
}

对于 redirect(),不要用一个过宽的 try/catch 把它吞掉。Next.js 的 redirect 通过特殊控制流结束当前渲染;如果捕获后继续返回普通状态,可能破坏预期重定向。通常应在事务成功后直接调用 redirect,或只捕获明确的业务异常。


八、程序化调用、bind 与参数安全

Server Action 不只可以由表单触发,也可以在客户端事件中调用:

'use client'

import { deleteProject } from './actions'

export function DeleteButton({ projectId }: { projectId: string }) {
  async function handleDelete() {
    await deleteProject(projectId)
  }

  return <button onClick={handleDelete}>删除</button>
}

这种写法仍然不改变安全模型:projectId 是客户端提供的输入,服务端必须再次确认当前用户拥有该项目。

如果需要绑定额外参数,可以使用 bind

const deleteForProject = deleteProject.bind(null, projectId)

return <form action={deleteForProject}>删除</form>

绑定参数也要符合可序列化要求。bind 只是改变 Action 的调用参数,不会把 projectId 变成可信数据,也不会自动完成授权。


九、生产环境中的边界和诊断

1. 调试时先确认 Action 是否真的被调用

遇到表单“不生效”,按以下顺序检查:

  1. 文件是否包含正确的 'use server' 指令;
  2. 导出的函数是否被正确导入;
  3. 使用 useActionState 时签名是否为 (previousState, formData)
  4. <form> 是否使用了 action={formAction}
  5. 字段是否有正确的 name
  6. 服务端日志中是否进入了 Action;
  7. 是否在认证阶段被重定向或拒绝;
  8. 是否在校验阶段返回了错误状态;
  9. 是否在数据库约束处失败;
  10. 浏览器 Network 面板中 POST 是否返回错误。

2. 观察序列化问题

典型失败表现包括:

  • 客户端调用时出现“参数不可序列化”;
  • 返回值在客户端变成意外结构;
  • ORM 对象或类实例无法传输;
  • FormData.get() 得到 nullFile,但代码直接当成字符串;
  • Error 作为状态返回后,客户端拿不到预期字段。

诊断方法是暂时将参数和返回值缩减为:

{
  ok: false,
  message: 'test',
}

如果简单对象可以工作,再逐个加入字段,定位具体的不可传输值。不要直接把数据库实体整体返回作为排查手段。

3. 请求体和文件上传限制

文件和大型 FormData 会受到框架、运行时、反向代理和部署平台的请求体限制。限制不是由 TypeScript 决定的,也不是把 File 放入表单就能绕过的。

大文件通常更适合:

  1. 服务端生成一次性上传凭证;
  2. 浏览器直接上传到对象存储;
  3. Server Action 只接收对象键和元数据;
  4. 服务端再次确认对象属于当前用户并完成入库。

这样可以避免让业务 Action 长时间持有大文件请求,也能更明确地处理上传失败和重试。

4. 事务不能自动覆盖外部副作用

下面的代码即使数据库事务成功,也不保证邮件只发送一次:

await db.$transaction(async (tx) => {
  await tx.order.create({ data: orderData })
  await email.send({ to, template: 'order-created' })
})

邮件服务不一定参与数据库事务。如果邮件发送成功而数据库提交失败,或者数据库提交成功而邮件调用超时,就会产生不一致。应将“订单创建”和“待发送邮件事件”写入同一数据库事务,再由可靠的后台消费者发送邮件,并使用邮件供应商支持的幂等键。


十、规范保证、框架实现与工程取舍

需要区分三个层次:

React/RSC 层

React Server Components 定义了服务端组件和服务端函数之间的协议模型,并规定哪些值可以穿过该协议。具体可序列化类型应以当前 React 版本文档为准。

Next.js 层

Next.js 负责把 'use server' Action 接入应用路由、表单和渲染生命周期,并提供 redirect、缓存更新等框架能力。请求来源检查、体积限制、运行时限制和部署行为可能随 Next.js 版本及运行环境变化。

应用层

以下内容必须由应用负责:

  • 当前用户是谁;
  • 当前用户能否操作目标资源;
  • 输入是否满足业务约束;
  • 重复请求是否产生重复副作用;
  • 数据库约束和事务边界;
  • 外部服务失败后的恢复策略。

最小可靠模型可以写成:

Action = 认证
       → 授权
       → 解析与校验
       → 幂等检查
       → 事务性业务操作
       → DTO / redirect

顺序可以因业务调整,但不能删除其中的安全边界。尤其要避免两种极端:

  • 只在客户端校验,服务器直接信任表单;
  • 只依赖框架自动处理,把 Action 当成不会重放的本地函数。

Server Actions 的价值在于减少手写 API 胶水代码,并让表单、服务端执行和渐进增强自然衔接;它并没有取消网络调用的基本事实。只要把 Action 当作可被重复调用的服务端入口,明确设计序列化数据、认证授权、运行时校验、幂等策略和错误状态,它就能在 React 19 与现代 Next.js 应用中形成清晰且可验证的边界。


系列导航与关联阅读

官方资料

本文依据 React 与生态项目官方文档重新梳理;正文与示例由 WR BLOG 编写。