表单看起来很简单:几个输入框,一个按钮,点击以后把数据存进数据库。真正把它放进协作产品,问题马上就变了。用户可能在 JavaScript 还没加载时提交,可能连续点击两次,也可能从两个标签页同时发布;浏览器交来的 FormData 没有类型保证,缓存不会因为数据库发生变化就自动知道该更新,附件的文件名和 MIME 类型也都不能直接相信。
这一章继续做 BoardFlow。我们不再拿“创建用户”这种孤立示例讲语法,而是沿着一张任务卡的生命周期往前走:成员创建任务,保存草稿,编辑后发布,留下评论,再上传附件。每一步都要回答四个问题:
本章以课程仓库实际安装的 Next.js 16.1.6、React 19.2.3 和 Zod 3.25.76 为基线。React 19 的正式文档使用 useActionState;旧资料常见的 useFormState 仍可能出现在兼容类型中,但不是本章采用的主 API。Zod 3 的错误整理使用 error.flatten()。仓库没有把 react-hook-form 声明为直接依赖,因此正文从浏览器表单、React Action 与服务端验证建立完整主线,不假设额外表单库已经可用。
完成本章后,BoardFlow 的写入路径会形成下面这条闭环:
很多文章把所有带 'use server' 的函数都叫 Server Action。这样讲入门快,却会把两个层次揉在一起。
Server Function 是在服务器上执行、可以被 React 跨网络引用的异步函数。Server Action 是被放进 Action 语境的 Server Function,例如传给表单的 action、按钮的 formAction,或在 Transition 中调用。换句话说,Server Action 描述的是“这个 Server Function 被拿来处理一次用户动作”,不是另一种函数语法。
当 action 的值是函数时,React 会接管提交:浏览器字段被整理为 FormData,请求按 POST 语义发送,返回的服务器组件结果与客户端状态再被 React 合并到当前界面。这里没有“浏览器直接调用服务器内存里的函数”。函数引用在客户端会变成协议中的引用,服务器仍然把它当作一个可到达的请求入口。

Server Action 不是隐藏的内部函数。只要它能进入客户端构建产物或被表单引用,就应把它看成一个公开的 POST 边界。攻击者不需要先看见按钮,也能构造请求。因此,隐藏按钮、只在 Server Component 中调用、使用 TypeScript 类型,都不能替代函数内部的认证、授权和输入验证。
下面的交互实验会把一次提交拆成浏览器收集字段、Action 请求、服务端校验、数据库写入和 React 回传五段。稍后生成资产时,这里会替换成真正的 iframe。
先做最小但真实的版本:成员在看板里输入标题、说明和优先级,提交后创建任务并进入详情页。我们先不加客户端 Hook,因为服务器表单本身就应该成立。
建议把写入函数放在离路由较近、名字明确的文件里:
app/
└── boards/
└── [boardId]/
├── actions.ts
└── tasks/
└── new/
└── page.tsx
lib/
├── auth/
│ └── require-board-permission.ts
└── db.ts// app/boards/[boardId]/actions.ts
'use server'
import { redirect } from 'next/navigation'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
function readText(formData: FormData, name: string): string {
const value = formData.get(name)
return
这个例子有意保留了最朴素的验证,方便先看清请求路径。boardId 虽然来自服务器渲染时的 bind,仍然会随请求回来,所以函数再次用它查权限。数据库写完后,updateTag 让当前成员接下来读到新的任务列表,最后才重定向。
// app/boards/[boardId]/tasks/new/page.tsx
import { createTask } from '../../actions'
type NewTaskPageProps = {
params: Promise<{ boardId: string }>
}
export default async function NewTaskPage({
params,
}: NewTaskPageProps) {
const { boardId } = await params
const createTaskForBoard
bind(null, boardId) 会把 boardId 放到 FormData 之前,成为函数的第一个参数。它比隐藏字段少暴露一段页面标记,也能保持渐进增强,但它不让参数变得可信。真正的可信关系来自服务端重新验证“当前用户是否能在这个 board 创建任务”。
这里也没有手写 method="post" 或 encType。当 action 是函数时,React 会管理方法和编码。普通文本表单与后面的文件表单都不应再用字符串表单时代的属性去覆盖它。
到这一步,即使浏览器禁用 JavaScript,表单仍然可以提交、创建任务并跳转。客户端代码应当增强这条路径,而不是成为它成立的前提。
'use server' 放在哪里,边界就画在哪里'use server' 有两种常用位置。它们都能创建 Server Function,但可见范围和导入方式不同。
// app/boards/[boardId]/tasks/[taskId]/page.tsx
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
type TaskPageProps = {
params: Promise<{ boardId: string; taskId: string }>
}
export default async function TaskPage({ params }: TaskPageProps
函数闭包捕获了 boardId 与 taskId。Next.js 会对需要发到客户端的闭包值加密,但“被加密”不等于“已经授权”。函数执行时仍然要读取会话并验证成员关系。
// app/boards/[boardId]/task-actions.ts
'use server'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
export async function completeTask(
boardId: string,
taskId: string,
): Promise<void> {
await requireBoardPermission(boardId, 'task:complete')
文件顶部使用指令后,运行时导出项必须是异步函数。共享常量、Zod schema 和普通同步工具函数应放到另一个没有 'use server' 的模块,再由 Action 文件导入。类型导出在编译后会被擦除,可以使用 export type,但不要从这个文件导出 initialState 之类的运行时对象。
Client Component 不能在组件函数内部声明内联 'use server' 函数。需要在客户端引用时,应从文件级 Server Function 模块导入:
// app/boards/[boardId]/complete-task-button.tsx
'use client'
import { useTransition } from 'react'
import { completeTask } from './task-actions'
type CompleteTaskButtonProps = {
boardId: string
taskId: string
}
export function CompleteTaskButton({
boardId,
taskId,
}: CompleteTaskButtonProps) {
const [isPending,
这里的 startTransition 把调用放进 Action 语境,并让 React 提供 pending 状态。对于有字段的提交,优先使用语义完整的表单;按钮式命令才适合这种调用。

渐进增强关注的不是“页面完全不用 JavaScript”,而是核心动作在脚本迟到、加载失败或水合尚未完成时仍有结果。Server Component 输出并直接绑定 Server Action 的函数表单,可以由浏览器在没有 JavaScript 时提交。Client Component 中引用 Server Action 的表单,如果脚本只是尚未加载,Next.js 会把提交排队并优先完成水合;使用 useActionState 的 permalink 后,React 还可以在 Server Function 表单于水合前提交时导航到一个稳定页面,并把返回状态接回同一表单。

假设新建任务表单也会出现在动态活动流里。活动流 URL 可能带筛选参数,不适合成为提交后的稳定落点。我们给表单一个永久地址:
// app/boards/[boardId]/tasks/new/task-draft-form.tsx
'use client'
import { useActionState } from 'react'
import { saveTaskDraft } from '../../task-form-actions'
import type { TaskFormState } from '@/lib/tasks/task-form-state'
const initialState: TaskFormState = {
status: 'idle',
message: '',
fieldErrors: {},
values: {
title: '',
description: '',
priority: 'medium'
对应的 Server Function 签名必须是:
export async function saveTaskDraft(
boardId: string,
previousState: TaskFormState,
formData: FormData,
): Promise<TaskFormState> {
// boardId 来自 bind
// previousState 与 formData 由 useActionState 依次补入
return previousState
}permalink 不是成功后的跳转地址,也不是随手填的字符串。它只参与“页面尚未完成水合时怎样延续这次 Action 状态”;页面可交互以后,这个参数不再影响提交。目标 URL 必须渲染同一个表单、同一个 Server Function,并保持相同的 permalink;否则 React 无法把服务器返回的状态接回正确组件。
渐进增强要求服务器返回有意义的结果。只在客户端 onSubmit 里调用 fetch、再用本地 state 拼出成功提示,脚本失效时整条路径也会消失。关键写入应先有服务端表单路径,再添加客户端体验。
浏览器会按字段名组装 FormData。服务端读取时,formData.get(name) 的结果是 string | File | null;复选框、多选列表等重复字段要用 getAll。TypeScript 里的 as string 只会让编译器闭嘴,不会阻止攻击者发来文件、空值或任意字符串。
下面这些值都应当视为不可信:
bind 传回来的参数;file.type;File.size 可限制收到的原始字节,却不能证明内容类型或解压后的资源消耗;// lib/tasks/task-input.ts
import { z } from 'zod'
export const taskInputSchema = z.object({
title: z
.string()
.trim()
.min(2, '标题至少需要 2 个字符')
.max(80, '标题不能超过 80 个字符'),
description: z
.string()
.trim()
这个 schema 使用 Zod 3.25.76 的 API。safeParse 不会因为普通字段错误抛异常,而是返回一个判别联合;错误可以通过 flatten() 整理为字段错误。
import {
taskInputSchema,
taskValuesFromFormData,
} from '@/lib/tasks/task-input'
const rawValues = taskValuesFromFormData(formData)
const parsed = taskInputSchema.safeParse(rawValues)
if (!parsed.success) {
return {
status: 'error',
message: '请检查标出的字段',
fieldErrors: parsed.error.flatten().fieldErrors,
values: rawValues,
}
}
不要对整个表单直接写 Object.fromEntries(formData) 就以为完成了转换。重复的 labelId 会被压成单值,文件字段仍然是 File,数字、布尔值和日期也不会自动变成业务类型;Server Action 表单还可能带有 $ACTION_ 前缀的协议字段。最好显式列出允许字段,让“浏览器字段怎样进入 schema”成为一段能审查的代码。

required、minLength、maxLength 和合适的输入类型能在浏览器里更早提示用户,也给辅助技术提供语义。它们不是安全边界,因为自定义请求可以完全绕开页面。
有了 schema,下一步不是把每个错误都 throw 出去。标题太短、描述太长、发布条件未满足,这些都是用户可以修正的预期失败。Action 应返回一个稳定、可序列化的状态,让表单知道错误属于哪个字段,并尽可能保留用户刚才的输入。
// lib/tasks/task-form-state.ts
export type TaskFormValues = {
title: string
description: string
priority: string
labelIds: string[]
}
export type TaskFormState = {
status: 'idle' | 'error' | 'saved'
message: string
fieldErrors: {
title?:
这个文件没有 'use server',所以可以安全导出对象。状态只包含字符串、字符串数组、普通对象和数字。数据库客户端实例、Error、类实例、React 元素和带方法的领域对象都不应塞进 Action state。
// app/boards/[boardId]/task-form-actions.ts
'use server'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
import {
taskInputSchema,
taskValuesFromFormData,
} from '@/lib/tasks/task-input'
import type { TaskFormState } from '@/lib/tasks/task-form-state'
export async function saveTaskDraft(
boardId: string,
previousState
一旦函数交给 useActionState,签名就从原来的 action(formData) 变成 action(previousState, formData)。这里又通过 bind 提前放入 boardId,所以最终顺序是 boardId → previousState → formData。参数顺序写错,是这类表单最常见也最隐蔽的错误之一。
// app/boards/[boardId]/tasks/new/task-draft-form.tsx
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { saveTaskDraft } from '../../task-form-actions'
import {
initialTaskFormState,
type TaskFormState,
} from '@/lib/tasks/task-form-state'
function FieldError({
id,
errors,
}: {
id: string
errors
这里使用 revision 作为 form 的 key。每次 Action 返回后,表单会按服务器给出的 values 重新挂载:验证失败时恢复刚才的输入,保存成功时显示空白的新表单。真实产品也可以改用受控字段,但必须明确处理“成功后清空”和“失败后保留”这两个结果。React 的函数 Action 成功完成后会重置非受控字段,不能把字段保留交给偶然行为。

状态机可以压缩成三条边:
idle → error:服务端拒绝预期输入,返回字段错误与原值;idle → saved:数据库写入成功,返回成功消息与空值;error.tsx,不伪装成字段错误。isPending 来自当前 useActionState,适合让整个表单显示忙碌状态;useFormStatus 读取最近父表单,适合让按钮、状态条等子组件响应提交。两者作用范围不同,不需要为了“统一”而只保留一个。
useFormStatus 来自 react-dom。在 React 19.2.3 中,它返回 pending、data、method 和 action:
它有一个很容易误解的限制:Hook 从组件所在位置向上查找最近的表单。一个组件在自己的返回值里创建 <form>,然后在同一个组件顶部调用 useFormStatus,读不到这个新表单的状态,因为那个表单不是它的父节点。
// 错误示意:这里读到的是更外层表单,或者始终是空闲状态
'use client'
import { useFormStatus } from 'react-dom'
export function TaskForm() {
const { pending } = useFormStatus()
return (
<form>
<button disabled={pending}>保存</button>
</form>
)
}正确做法是把需要状态的部分拆成表单子组件:
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitFeedback() {
const { pending, data } = useFormStatus()
const titleValue = data?.get('title')
const title =
typeof titleValue === 'string' ? titleValue.trim() : ''
return
按钮文字从“保存任务”变成“保存中…”,同时 role="status" 给辅助技术一条非阻断提示。不要只把按钮改成灰色;颜色本身不能说明发生了什么。对于耗时稍长的提交,还可以在 form 上设置 aria-busy。
禁用 pending 按钮只是一层用户体验保护。它减少连续点击,却不能阻止刷新重试、两个标签页、移动网络重传或直接构造请求。真正的重复写入要靠后文的服务端幂等设计。
编辑任务时,“保存草稿”和“发布任务”通常共享标题、说明、优先级等字段,却有不同的业务结果。HTML 按钮支持 formAction,React 也允许它接收另一个 Server Action。这样不必复制整张表单,也不必用客户端点击事件手工拼请求。
// app/boards/[boardId]/task-lifecycle-actions.ts
'use server'
import { redirect } from 'next/navigation'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
import {
taskInputSchema,
taskValuesFromFormData,
} from '@/lib/tasks/task-input'
export async function saveExistingDraft(
boardId: string,
taskId:
这个版本为了突出多动作结构,把验证失败交给异常边界。生产表单通常会像上一节那样返回字段状态。无论反馈方式是什么,两个函数都必须各自认证、授权和验证,不能因为“发布按钮只有管理员看得见”就让 publishTask 少一道检查。
// app/boards/[boardId]/tasks/[taskId]/task-editor.tsx
import {
publishTask,
saveExistingDraft,
} from '../../task-lifecycle-actions'
import { TaskActionButtons } from './task-action-buttons'
type TaskEditorProps = {
boardId: string
task: {
id: string
title: string
description: string | null
priority: 'low' | 'medium'
// app/boards/[boardId]/tasks/[taskId]/task-action-buttons.tsx
'use client'
import { useFormStatus } from 'react-dom'
type FormAction = (formData: FormData) => Promise<void>
type TaskActionButtonsProps = {
saveDraftAction: FormAction
publishAction: FormAction
}
export function TaskActionButtons({
saveDraftAction,
publishAction
按下 Enter 时,浏览器通常走表单自身的 action,也就是保存草稿。明确设置默认 Action 能避免键盘提交意外触发发布。useFormStatus().action 则能指出这次点击实际选择了哪个函数。这里还把 Client Component 的函数 props 命名为 saveDraftAction 和 publishAction;这种 action / Action 后缀让 Next.js 的 TypeScript 检查器能把它们识别为可跨边界传递的 Server Function,而不是普通客户端函数。
还有一种做法是让两个按钮共享一个 Action,通过 name="intent" 和 value="publish" 分支。它适合两个动作的大部分权限和事务都相同的场景;如果发布比保存需要更高权限,拆成两个函数更容易审查。
表单字段通过 Zod,不代表当前请求有权修改这张任务。安全检查至少分成三个问题:
它们不能互相替代。登录用户也可能越权,合法 Origin 也可能提交恶意字段,Zod 验证成功也不能证明成员关系。
// lib/auth/require-board-permission.ts
import { redirect } from 'next/navigation'
import { db } from '@/lib/db'
import { verifySession } from '@/lib/auth/session'
type BoardPermission =
| 'task:create'
| 'task:edit'
| 'task:publish'
| 'task:complete'
| 'comment:create'
| 'attachment:create'
const rolePermissions: Record<string, Set<
真实查询还应把 taskId 和 boardId 一起放进更新条件,避免只验证“用户属于某个 board”,却更新了另一个 board 的任务。updateMany({ where: { id, boardId } }) 返回受影响行数后,也应检查是否为 1;为 0 可能表示资源不存在、已改变状态或发生竞争。
Server Actions 只接受 POST。收到带 Origin 的请求时,Next.js 会比较它与 Host 或 X-Forwarded-Host;不匹配时默认中止 Action。这是框架提供的 CSRF 防线。当前 16.1.6 处理器遇到缺少 Origin 的旧客户端请求会记录警告而不是直接拒绝,因此 SameSite Cookie、认证与 Action 内授权仍不能省略。反向代理确实会让公开 Origin 与应用看到的 Host 不同,才需要在配置中增加明确的允许来源:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
serverActions: {
allowedOrigins: [
'boardflow.example.com',
'*.internal.boardflow.example.com',
],
},
},
}
export default nextConfigallowedOrigins 写主机模式,不要为了排除代理问题就放开到不受控域名。还要确认代理正确覆盖 Host / X-Forwarded-Host,并清理客户端伪造的转发头。这个配置只调整来源检查,不会替你完成用户授权。
不要从隐藏字段读取 userId,也不要让客户端提交角色后直接写数据库。用户身份应来自服务器验证过的会话;资源权限应由服务器查询成员关系。隐藏字段适合携带普通上下文,不适合携带“相信我有权限”的结论。
用户把标题写得太短,与数据库连接中断,不应显示成同一种“提交失败”。我们可以用一个简单判断来分流:
redirect 通过抛出一个特殊控制流信号终止当前渲染。若把它放进宽泛的 try/catch,就可能被当成普通错误吞掉。
// app/boards/[boardId]/publish-actions.ts
'use server'
import { redirect } from 'next/navigation'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
type PublishState = {
status: 'idle' | 'error'
message: string
revision: number
}
export async function
验证与状态竞争是调用者能理解的结果,所以返回 state。数据库异常不是用户改一个字段就能修复的,记录服务端上下文后继续抛出。日志里不要写完整 FormData、Cookie、令牌或附件内容。
// app/boards/[boardId]/error.tsx
'use client'
import { useEffect } from 'react'
export default function BoardError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error
reset() 会尝试重新渲染错误边界所在的路由段,不会自动重放刚才失败的写入。用户回到表单后应先看服务器当前事实,再决定是否重新提交;这也是写入仍需幂等的原因。对外文案保持克制,不把 SQL、堆栈或资源是否存在等信息直接交给用户。服务端日志用请求编号、用户编号和资源编号关联排查,但应避开敏感值。
数据库成功写入只是第一步。若任务列表来自带标签的缓存,界面可能继续显示旧数据;若当前客户端树里还有本地状态,也可能需要重新请求服务器组件。Next.js 16.1.6 提供的几个 API 解决的是不同问题。
updateTag 与 refresh 只能在 Server Action 中使用。revalidateTag 和 revalidatePath 可以在 Server Function 与 Route Handler 中调用,但不能放进 Client Component、Proxy、渲染过程或缓存函数内部。Next.js 16 推荐给 revalidateTag 传 'max',让下次访问先返回旧内容、再后台更新;单参数的立即过期用法已弃用,安装类型也要求第二个参数。
标签必须先由读取路径通过 fetch(..., { next: { tags } }),或在启用 Cache Components 后由缓存函数的 cacheTag 绑定。随手调用一个从未绑定到缓存数据的字符串,不会建立关系,也不会让普通数据库查询凭空获得缓存能力。本章示例假设任务与评论读取端已经使用相同标签;完整缓存设计留到缓存章节。

下面的实验台会让读者选择“数据是否有 tag、是否必须 read-your-writes、是否要换页”,再观察不同 API 的可见结果。
添加评论时,评论列表有明确标签,而且用户期望马上看到刚写的内容:
// app/boards/[boardId]/comment-actions.ts
'use server'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
export async function addComment(
boardId: string,
taskId: string,
formData: FormData,
): Promise<{ ok: true }
如果“本周任务数”允许短暂显示旧值,可以在写入后使用:
import { revalidateTag } from 'next/cache'
revalidateTag(`board:` + boardId + `:weekly-stats`, 'max')如果一个写入改变了某个路由的整体结构,例如任务从草稿区移动到已发布区,而且数据没有统一标签,可以按路径处理:
import { revalidatePath } from 'next/cache'
revalidatePath(`/boards/` + boardId)如果当前页面读的是不缓存的个性化数据,只需要重新拉取当前 RSC 树:
'use server'
import { refresh } from 'next/cache'
import { db } from '@/lib/db'
import { verifySession } from '@/lib/auth/session'
export async function dismissTaskNotice(noticeId: string) {
const session = await verifySession()
if (!session) {
throw new Error('UNAUTHENTICATED')
}
refresh() 不会让带标签的 Data Cache 自动过期。updateTag 只处理命中该标签的数据;如果 Action 返回时自然重渲染了这些读取端,通常不必再机械调用 refresh。如果当前客户端还依赖没有被该标签覆盖的动态数据,refresh 仍可能有用。先找出数据的缓存方式和界面读取范围,再选最小组合。
先完成认证、授权和输入验证。被拒绝的请求不应触碰数据库,也不需要失效缓存。
在事务中提交真正的数据变化。只有数据库确认成功,后续界面更新才有事实依据。
根据读取方式选择 updateTag、revalidateTag、revalidatePath 或 refresh。不要靠“多调用几个更保险”来掩盖缓存模型不清楚。
最后调用 redirect。因为 redirect 会终止当前控制流,放在它之后的失效代码不会执行。
评论是一个适合 useOptimistic 的场景。用户点击发送后,我们可以先把一条带“发送中”标记的评论放进列表;服务端成功后,真正的评论从服务器数据回来,临时项消失;失败时临时项回滚并显示错误。
发布任务、扣款、删除整个看板则不适合只靠乐观界面制造“已经完成”的感觉。这些动作影响大,失败后的恢复成本也高。
// app/boards/[boardId]/tasks/[taskId]/comment-list.tsx
'use client'
import { useOptimistic, useState } from 'react'
import { addComment } from '../../comment-actions'
type Comment = {
id: string
body: string
authorName: string
pending?: boolean
}
type CommentListProps = {
boardId: string
taskId: string
传给表单 action 的异步函数本身处于 Action 语境,所以可以在提交开始时调用 appendOptimistic。若是在普通点击回调里触发乐观更新,应把它放进 startTransition。
useOptimistic 没有替代数据库数据。它只在异步 Action 进行期间提供一个临时视图;Action 完成后,状态回到服务器传入的 comments。上一节的 updateTag 会让成功写入的评论随新的服务器组件结果回来。服务端返回 { ok: false } 时,代码显示可修正消息;服务端直接抛错时,错误交给边界。只要基础 comments 没有被错误地改写,这两条失败路径都会让临时项回退。
输入框在这里使用受控 draft:成功后主动清空,失败时保留原文。这个乐观包装函数依赖客户端完成水合;需要覆盖“完全无 JavaScript 也能评论”的产品,应另外保留直接指向 addComment 的服务器表单路径,再把乐观列表当作客户端增强。不要把一个只能在浏览器运行的包装函数误写成渐进增强基线。
评估一个动作是否适合乐观更新,可以问三个问题:
乐观 UI 不能证明服务端成功,也不能承担幂等职责。不要因为界面已经出现一条评论,就跳过服务端结果;也不要用客户端生成的临时 ID 直接当成数据库主键和授权凭据。
Next.js 客户端当前会逐个派发并等待 Server Function,但官方明确把它列为可能改变的实现细节,不能当作全局锁。两个标签页是两个客户端,用户可以刷新重提,代理可能重试,攻击者也能直接发 POST。即使按钮在第一毫秒就变成 disabled,服务器仍可能同时收到两次语义相同的请求。
真正的目标不是“尽量少点两次”,而是:同一个业务意图被重复执行时,数据库只产生一个结果;相同幂等键若搭配了不同内容,服务器明确拒绝。

BoardFlow 可以为“创建任务”生成一次性意图键。数据库保存四类信息:
CREATE TABLE mutation_receipt (
id UUID PRIMARY KEY,
actor_id UUID NOT NULL,
idempotency_key UUID NOT NULL,
payload_fingerprint TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('processing', 'completed')),
response_json JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
completed_at TIMESTAMPTZ,
UNIQUE
唯一约束的作用范围是 actor_id + idempotency_key。同一把键不能让另一个用户读取结果;payload_fingerprint 则阻止调用者拿已经成功的键换一份任务内容。
服务端渲染表单时生成键:
// app/boards/[boardId]/tasks/new/idempotent-task-form.tsx
import { randomUUID } from 'node:crypto'
import { redirect } from 'next/navigation'
import { createTaskOnce } from '../../idempotent-task-actions'
export function IdempotentTaskForm({
boardId,
}: {
boardId: string
}) {
const idempotencyKey = randomUUID()
async function createAndOpen(formData: FormData
隐藏字段仍然不可信。服务器必须验证它是 UUID,再让数据库唯一约束承担最终裁决。幂等键只表达“这是哪一次意图”,不表达“谁有权限”。这个外层表单为保持示例聚焦,只把预期失败重定向成稳定状态码;实际编辑表单应沿用前文的 useActionState,返回字段错误并保留用户输入。
// app/boards/[boardId]/idempotent-task-actions.ts
'use server'
import { createHash, randomUUID } from 'node:crypto'
import { updateTag } from 'next/cache'
import { z } from 'zod'
import { db } from '@/lib/db'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
import {
taskInputSchema,
taskValuesFromFormData,
} from '@/lib/tasks/task-input'
import { isUniqueConstraintError } from '@/lib/db/errors'
const idempotencyKeySchema
第一次事务会同时创建回执和任务。第二个并发请求在唯一约束处失败,随后读取第一次已经提交的回执:指纹相同就返回原任务编号,指纹不同就拒绝。标签编号先排序,避免同一组标签仅因提交顺序不同就产生不同指纹。数据库驱动的唯一错误判断还要核对冲突约束或字段,不能把任务表上另一个唯一冲突误判成幂等重放,也不要靠匹配英文错误消息。
重放路径再次调用 updateTag,用于覆盖“事务已经提交,但进程在第一次缓存失效前退出”的窄窗口。回执需要设置与客户端重试周期匹配的保留时间和清理任务;清理太早会把迟到重试重新当成新请求,永久保留则会让表无限增长。
如果创建任务还要发邮件或推送,不能把网络调用塞进事务后又宣称“恰好一次”。更稳妥的方式是在同一事务写一条 outbox 事件,由后台消费者发送,并让消费者本身也按事件编号幂等。这样数据库事实与待发送事件不会一半成功、一半丢失。
客户端 pending、服务端幂等键、数据库唯一约束和事务分别解决不同层次的问题。pending 减少误操作;键标识意图;唯一约束裁决并发;事务让回执与业务数据一起提交。少一层都可能在特定故障下露出缝隙。
FormData 可以包含 File,Server Action 也能接收文件,但“能收到”不等于“适合把所有文件都穿过 Action”。Next.js 16.1.6 的 Server Action 请求体默认上限是 1 MB,而且 multipart 边界与其他字段也计入总大小。一个标称 1 MB 的文件,整个请求通常会超过默认限制;在当前 Node Action 处理器中,超过限制会以 413 终止,托管平台还可能有更小的上游限制。
对于 BoardFlow,我们把附件分成两类:
import { uploadSmallAttachment } from './attachment-actions'
export function AttachmentForm({
boardId,
taskId,
}: {
boardId: string
taskId: string
}) {
const upload = uploadSmallAttachment.bind(null, boardId, taskId)
return (
<form action={upload}>
<label
传统字符串 URL 表单需要 encType="multipart/form-data"。当 action 是函数时,React 会处理编码,手写 method 或 encType 反而会被覆盖并触发警告。accept 只是文件选择器提示,攻击者可以绕开。
// app/boards/[boardId]/tasks/[taskId]/attachment-actions.ts
'use server'
import { randomUUID } from 'node:crypto'
import { updateTag } from 'next/cache'
import { db } from '@/lib/db'
import { storage } from '@/lib/storage'
import { requireBoardPermission } from '@/lib/auth/require-board-permission'
const MAX_ACTION_FILE_BYTES = 750_000
type DetectedFile = {
extension: 'png' | 'pdf'
contentType:
这段代码先用 boardId + taskId 确认资源归属,没有用原始文件名做对象键,也没有仅靠 entry.type 判断格式。entry.size 适合限制服务器实际收到的原始字节;它不能告诉我们压缩包展开后多大,也不能证明文件内容安全。PNG 和 PDF 的文件头检查只是最小示范;真实系统还应使用成熟解析器,限制解压后的大小与图片像素,做恶意软件扫描,并在扫描通过前保持 quarantine 状态。
对象存储与数据库不是同一个事务。示例在登记失败时尽力删除对象,同时记录清理失败;生产环境还应按 quarantine 前缀和上传会话定期回收孤儿对象。公开下载时应设置安全的 Content-Disposition,不要让上传内容在应用主域名下随意以内联 HTML 执行。

如果业务明确需要略大 Action 请求,可以配置:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
serverActions: {
bodySizeLimit: '2mb',
},
},
}
export default nextConfig这个设置改变请求解析上限,不会给文件扫描、流式传输或内存容量。十个并发 20 MB 上传可能让应用进程同时缓冲大量数据,所以“把限制调到 50 MB”通常不是大文件方案。
大文件更适合这条流程:
客户端先调用 Server Action,请求一个短期、限定对象键和大小的预签名上传地址;Action 验证会话、任务权限与配额。
浏览器把文件直接上传到对象存储。应用服务器不读取整份文件,也不替客户端提供无限制的存储凭据。
上传完成后,客户端把对象键和上传会话编号提交给 finalize Action。服务器重新检查对象大小、服务端识别的类型和所有者。
对象先进入隔离区,异步扫描通过后才标记为可下载;失败或超时对象由清理任务删除。
不要把用户上传内容直接写进代码仓库的 public 目录。无服务器或多实例部署里,本地磁盘通常不持久,也不在实例之间共享;公开目录还会绕过隔离和下载授权。对象存储与数据库元数据才是合适边界。
Server Function 跨越了客户端与服务器,参数和返回值必须符合 React 的序列化协议。为了让表单状态容易维护,本章一直把返回值限制在普通对象、字符串、数字、布尔值、数组与 null。
内联 Server Action 捕获的变量需要随服务器组件结果到达客户端。Next.js 会加密这些闭包值,并且默认在每次构建生成新的私钥。它降低明文暴露,但没有改变两件事:
自托管到多个实例时,各实例必须使用同一把构建密钥,否则 A 实例生成的闭包载荷交给 B 实例可能无法解密。NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 必须是 Base64 编码的 16、24 或 32 字节 AES 密钥,Next.js 默认生成 32 字节密钥。它在构建时嵌入产物,因此要在 next build 时由 secret 管理器提供,并让同一构建的所有实例使用同一产物;滚动窗口中需要共存的构建也应按密钥轮换方案保持兼容。不要把密钥提交进仓库,也不要在日志中打印。
Server Action 标识与构建产物绑定。用户长时间开着旧标签页,期间服务器切到新构建,再点击旧页面按钮,就可能提交一个新版本已不认识的 Action 标识。滚动发布如果同时运行多个版本,也会出现请求落到不匹配实例的情况。

可以从五层降低版本偏斜:
deploymentId,让 Next.js 在资源请求与导航中识别部署版本;// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
deploymentId: process.env.DEPLOYMENT_VERSION,
experimental: {
serverActions: {
bodySizeLimit: '2mb',
allowedOrigins: ['boardflow.example.com'],
},
},
}
export default nextConfig配置 deploymentId 后,本地静态资源 URL 会带 ?dpl=<deploymentId>,客户端导航与 Server Action 请求会携带 x-deployment-id。服务器发现导航版本不匹配时会触发整页加载,让浏览器回到同一发布版本的资源。它不会替服务器保存所有旧 Action 实现;负载均衡、旧版本保留时间和密钥分发仍然是部署架构的一部分。
如果项目使用 output: 'export' 生成纯静态站点,就没有 Next.js 服务器接收 Server Action。此时表单必须提交到外部 API、托管表单服务或另一个动态后端。不要在开发服务器里看到 Action 能运行,就认为导出的静态文件也带着服务器。
bind 参数、隐藏字段和加密闭包的共同点是:它们都能携带上下文,却都不能替代授权。真正可信的结论应在 Action 执行时由服务器根据会话与数据库关系重新计算。
表单测试不能只调用一个函数然后断言返回值。我们需要分层:纯 schema 测输入,领域服务测事务与权限,浏览器测试真正的提交、导航和无 JavaScript 路径。当前课程仓库没有声明 Vitest 或 Playwright 为直接依赖,下面代码展示的是团队加入测试工具后的组织方式,不假装这些命令今天已经存在。
// lib/tasks/task-input.test.ts
import { describe, expect, it } from 'vitest'
import {
taskInputSchema,
taskValuesFromFormData,
} from './task-input'
describe('taskInputSchema', () => {
it('整理空格并保留多个标签字段', () => {
const formData = new FormData()
formData.set('title', ' 修复登录页 ')
formData.set
领域服务测试还应覆盖:
connect 关联;Action 标识属于构建协议,测试不应硬编码它。通过可见表单提交,才能一起验证字段名、Server Action 绑定、缓存更新与重定向。
// e2e/create-task.spec.ts
import { expect, test } from '@playwright/test'
test('成员创建任务并进入详情页', async ({ page }) => {
await page.goto('/boards/demo/tasks/new')
await page.getByLabel('任务标题').fill('整理发布检查表')
await page.getByLabel('任务说明').fill('覆盖回滚与缓存检查')
还应增加两个浏览器场景:先提交非法数据,确认错误与字段关联且输入被保留;再用两个并发页面提交相同幂等键,确认最终只有一个任务。
一个可用表单至少要做到:
label,htmlFor 与 id 对应;fieldset 和 legend 分组;aria-invalid="true";aria-describedby 指向字段的帮助与错误文本;role="status" 或合适的 live region,不只改变颜色;前面的 FieldError 只通过 aria-describedby 与输入框关联,没有给每个字段都加 role="alert",是为了避免一次提交同时触发多段抢占式播报。错误摘要集中宣布一次,再让用户移动到字段时听到具体错误;最终效果仍要用目标读屏软件验证。
下面是一个聚焦错误摘要的最小组件:
'use client'
import { useEffect, useRef } from 'react'
import type { TaskFormState } from '@/lib/tasks/task-form-state'
export function TaskErrorSummary({
state,
}: {
state: TaskFormState
}) {
const summaryRef = useRef<HTMLDivElement>(null)
const hasErrors =
state.status === 'error' &&
自动化可检查标签、名称和常见 ARIA 问题,但仍要用键盘走一遍:Tab 顺序是否合理,焦点是否看得见,错误后焦点去了哪里,Enter 会不会误发布,读屏是否把“发送中”和失败原因读出来。
现在把本章内容收束成一个完整练习:为 BoardFlow 实现“创建并发布任务”。要求页面在 JavaScript 关闭时仍能创建草稿;启用 JavaScript 后显示字段错误、pending 和成功反馈;经理可以发布,普通成员只能保存;重复请求不会创建两张任务;任务列表在成功后读到新数据。
先画清边界。Server Component 读取 board 与标签选项;Client Component 只承担提交状态;独立的文件级 Server Functions 负责保存与发布。
用显式函数把 FormData 整理成原始对象,再交给 Zod 3 schema。不要把隐藏字段、bind 参数或 TypeScript 断言当成可信证明。
在每个 Action 内验证会话、board 成员关系和具体 capability。发布函数单独要求 task:publish,查询与更新条件同时包含 boardId 和 taskId。
用 useActionState 返回可序列化的字段状态,把 useFormStatus 放进按钮子组件。给动态入口设置稳定 permalink,并实际关闭 JavaScript 验证核心路径。
下面的代码有多处问题。先自己找,再展开参考答案。
'use server'
export async function publish(formData: FormData) {
const userId = formData.get('userId') as string
const taskId = formData.get('taskId') as string
const title = formData.get('title') as string
try {
最后可以用这张表做提交前检查:
当这十行都能给出代码或测试证据时,BoardFlow 的表单才不只是“按钮点了有反应”。它已经是一条可以在失败、重试、并发和部署切换中继续解释自己行为的写入路径。
为保存与发布设置不同 formAction,明确 Enter 的默认动作。预期失败返回 state,未知故障抛给 error.tsx,redirect 放在 try/catch 外。
在事务里写任务与幂等回执,依靠数据库唯一约束裁决并发。成功提交后根据读取方式选择 updateTag、revalidateTag、revalidatePath 或 refresh。
最后补齐浏览器测试、权限测试、并发测试与键盘测试。只有界面反馈、服务器事实和缓存读取一致,这次提交才算完成。