Next.js Server Actions 教程:表单处理与验证最佳实践
周五晚上十点半,你坐在电脑前,盯着一个用户注册表单的代码。文件夹里已经堆了四个文件:表单组件、API Route、类型定义、错误处理…你突然意识到,为了处理一个简单的表单提交,竟然写了快 200 行代码。
有没有更简单的方式?
答案是 Server Actions。这个 Next.js App Router 里的特性,能把表单处理流程简化 80%。不用写 API Route,不用手动 fetch,甚至不需要那些繁琐的状态管理。听起来很美好,但你可能也有疑问:这玩意真的安全吗?验证怎么做?Loading 状态咋处理?
说实话,我刚开始用的时候也有这些顾虑。用了几个月后,踩过坑也总结了些经验,想跟你聊聊 Next.js Server Actions 在表单处理中的实战技巧——从最基础的提交,到 Zod 验证、安全防护、用户体验优化,这篇文章会通过真实代码示例,让你快速上手这个特性。
Server Actions 基础
什么是 Server Actions?
Server Actions 就是运行在服务端的异步函数,你用 'use server' 标记它,然后就能直接在表单的 action 属性里使用。这样一来,表单提交时自动调用这个函数,数据处理、数据库操作、缓存更新…全在服务端完成。
关键特点是:
- 类型安全:TypeScript 能检查到整个链路
- 零配置:不用创建
/api文件夹 - 自动处理:FormData 自动传进来
两种写法,你可以把 Server Action 直接写在组件里(内联),也可以单独放一个文件(模块级):
// 方式1:内联在组件中
export default function Page() {
async function createUser(formData: FormData) {
'use server' // 标记为 Server Action
const name = formData.get('name')
// 处理数据...
}
return <form action={createUser}>...</form>
}
// 方式2:独立文件(推荐)
// app/actions.ts
'use server' // 文件级别标记
export async function createUser(formData: FormData) {
const name = formData.get('name')
// 处理数据...
}
你可能会问:Server Actions 和传统的 API Routes 有啥区别?啥时候该用哪个?
我整理了一张对比表:
| 特性 | Server Actions | API Routes |
|---|---|---|
| 用途 | 表单提交、数据变更 | RESTful API、外部调用 |
| HTTP 方法 | 只支持 POST | 支持 GET/POST/PUT/DELETE 等 |
| 类型安全 | 天然类型安全 | 需要手动定义类型 |
| 调用方式 | 直接调用函数 | fetch 请求 |
| 适合场景 | 内部逻辑、表单 | 公开 API、第三方集成 |
| 代码量 | 少 | 相对多 |
简单来说:内部用 Server Actions,对外用 API Routes。如果只是处理自己应用里的表单,Server Actions 够了。但要给其他系统提供接口、或者需要 GET 请求的话,还是得用 API Routes。
根据 Vercel 2025 年的调查,已经有 63% 的开发者在生产环境用上了 Server Actions。这不是什么实验性特性了。
"已经有 63% 的开发者在生产环境用上了 Server Actions"
第一个 Server Actions 示例
直接上代码,看个最简单的登录表单:
// app/login/page.tsx
export default function LoginPage() {
async function handleLogin(formData: FormData) {
'use server' // 标记为服务端函数
// 从表单获取数据
const email = formData.get('email') as string
const password = formData.get('password') as string
// 处理登录逻辑(这里简化演示)
console.log('登录尝试:', email)
// 实际项目中这里会验证用户、生成 token 等
}
return (
<form action={handleLogin}>
<input
type="email"
name="email"
placeholder="邮箱"
required
/>
<input
type="password"
name="password"
placeholder="密码"
required
/>
<button type="submit">登录</button>
</form>
)
}
就这么简单。关键点:
'use server':告诉 Next.js 这个函数要在服务端跑formData.get():用字段的name属性获取值action={handleLogin}:表单提交时自动调用
运行效果是:点击提交按钮,浏览器不会刷新页面,数据直接发到服务端处理。比传统方式少写了一堆 fetch、useState、错误处理…
但这只是最基础的。真实项目里,你还得做验证、显示错误、处理 Loading 状态。接着往下看。
表单验证实战
使用 Zod 做表单验证
只靠客户端的 required 属性?太天真了。用户随便打开个浏览器开发工具,就能绕过这些验证。服务端验证是必须的。
这就是 Zod 派上用场的地方。它能在服务端验证数据格式,发现问题立刻返回错误,防止脏数据进数据库。
先装 Zod:
npm install zod
然后定义验证规则:
// app/actions.ts
'use server'
import { z } from 'zod'
// 定义验证 schema
const SignupSchema = z.object({
name: z.string().min(2, '姓名至少 2 个字符'),
email: z.string().email('邮箱格式不对'),
password: z.string().min(8, '密码至少 8 位'),
})
export async function signup(formData: FormData) {
// 从 FormData 提取数据
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
}
// 验证数据
const result = SignupSchema.safeParse(rawData)
// 验证失败,返回错误
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors, // 字段级别的错误
}
}
// 验证通过,处理业务逻辑
const { name, email, password } = result.data
// 创建用户、保存数据库等...
console.log('创建用户:', { name, email })
return {
success: true,
message: '注册成功!',
}
}
关键点:
safeParse不会抛异常:失败时返回{ success: false, error: ... },你可以优雅地处理错误flatten().fieldErrors:把验证错误转成{ name: ['错误1'], email: ['错误2'] }这种格式,方便展示- 返回结构化数据:包含
success标志和错误信息,客户端根据这个决定怎么展示
但现在还有个问题:怎么把这些错误显示在表单里?这就需要 useActionState 了。
展示验证错误:useActionState
useActionState 是 React 19 引入的 Hook(之前叫 useFormState),专门用来处理 Server Actions 返回的状态。它会:
- 把服务端返回的数据保存到组件状态
- 提供一个包装后的 action 函数
- 告诉你表单是否正在提交
先看代码:
// app/signup/page.tsx
'use client' // 使用 Hook 需要标记为客户端组件
import { useActionState } from 'react'
import { signup } from '@/app/actions'
export default function SignupPage() {
// 定义初始状态
const initialState = { success: false, errors: {}, message: '' }
// useActionState 接收:Server Action 和初始状态
const [state, formAction, isPending] = useActionState(signup, initialState)
return (
<form action={formAction}> {/* 用 formAction 替代原始 action */}
<div>
<label>姓名</label>
<input
type="text"
name="name"
required
/>
{/* 显示字段错误 */}
{state.errors?.name && (
<p className="error">{state.errors.name[0]}</p>
)}
</div>
<div>
<label>邮箱</label>
<input
type="email"
name="email"
required
/>
{state.errors?.email && (
<p className="error">{state.errors.email[0]}</p>
)}
</div>
<div>
<label>密码</label>
<input
type="password"
name="password"
required
/>
{state.errors?.password && (
<p className="error">{state.errors.password[0]}</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? '提交中...' : '注册'}
</button>
{/* 显示成功消息 */}
{state.success && (
<p className="success">{state.message}</p>
)}
</form>
)
}
工作流程是:
- 用户提交表单 → 调用
signup - 服务端验证失败 → 返回
{ success: false, errors: {...} } useActionState把这个结果存到state里- 组件重新渲染,显示错误信息
isPending 这个值在表单提交时为 true,完成后变 false,你可以用它来禁用按钮、显示 Loading 文字。
但你可能注意到了:用户填的内容在验证失败后会丢失。要保留表单数据,可以在返回时加上 values 字段,然后用 defaultValue 设置到输入框。这里就不展开了,重点是理解 useActionState 的作用:连接客户端组件和 Server Actions,让状态管理变简单。
用户体验优化
Loading 状态与防重复提交
上面用了 isPending 来显示 Loading,但其实还有另一个 Hook:useFormStatus。这俩容易搞混,我一开始也懵过。
简单说:
useActionState的isPending:适合在表单组件里用useFormStatus的pending:适合在表单的子组件(比如提交按钮)里用
useFormStatus 有个限制:必须在 <form> 的子组件里调用,不能直接在表单组件用。这听起来麻烦,但好处是可以把按钮抽成独立组件复用。
看个例子,把提交按钮拆出来:
// components/SubmitButton.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus() // 获取表单提交状态
return (
<button
type="submit"
disabled={pending}
className={pending ? 'loading' : ''}
>
{pending ? '提交中...' : children}
</button>
)
}
然后在表单里直接用:
// app/signup/page.tsx
'use client'
import { useActionState } from 'react'
import { signup } from '@/app/actions'
import { SubmitButton } from '@/components/SubmitButton'
export default function SignupPage() {
const [state, formAction] = useActionState(signup, { success: false, errors: {} })
return (
<form action={formAction}>
{/* 表单字段... */}
<SubmitButton>注册</SubmitButton> {/* 自动处理 Loading */}
{state.errors?.general && (
<p className="error">{state.errors.general}</p>
)}
</form>
)
}
这样一来,按钮的 Loading 逻辑完全封装好了。在提交时:
- 按钮自动禁用,防止重复提交
- 文字变成”提交中…”
- 可以加个转圈动画
pending 和 isPending 的区别?
| 特性 | useActionState 的 isPending | useFormStatus 的 pending |
|---|---|---|
| 调用位置 | 表单组件内部 | 表单的子组件内部 |
| 适用场景 | 需要访问表单整体状态 | 只关心提交状态的独立按钮 |
| 灵活性 | 可以同时获取 state 和 pending | 只能获取 pending |
实际项目里,我一般这样用:
- 表单逻辑复杂、需要处理多种状态 → 用
useActionState - 只是做个通用的提交按钮 → 用
useFormStatus
渐进式增强
有个挺酷的特性:Server Actions 支持渐进式增强。啥意思?就是即使用户浏览器禁用了 JavaScript,表单依然能提交。
这是因为 Server Actions 本质上还是利用浏览器原生的 <form> 提交机制。Next.js 在有 JavaScript 的情况下会劫持提交过程,做成 AJAX 请求;没 JavaScript 时,就退化成传统的表单提交。
实际应用场景?老实说不多。现在哪个网站没 JavaScript 还能用…但对无障碍访问、爬虫友好性来说,这是个加分项。而且你啥都不用做,Next.js 自动搞定。
安全性与最佳实践
Server Actions 的安全性
这是最容易被忽视的部分。很多人以为 Server Actions 运行在服务端,就自动安全了。大错特错。
Server Actions 本质上就是公开的 API 端点。虽然 Next.js 给它生成了一个难猜的 ID,但这只是”模糊化”,不是真正的安全措施。懂点技术的人,打开浏览器开发工具看看网络请求,就能找到这个 Action 的 ID,然后手动调用它。
Next.js 提供了一些内置保护:
- CSRF 防护:Server Actions 只能通过 POST 请求调用,且会检查 Origin 和 Host 头是否匹配。跨站请求会被拒绝。
- 安全的 Action ID:每个 Action 都有个加密的 ID,不容易被穷举。
- 闭包变量加密:如果你在 Action 里用了外部变量,Next.js 会加密它们。
但这些远远不够。你必须做这些事:
1. 输入验证
永远不要信任客户端数据。前面讲过用 Zod 验证,这是必须的。
2. 身份认证
检查用户是否登录。每个需要权限的 Action,都要验证身份。
3. 权限验证
登录不等于有权限。比如用户 A 不能删除用户 B 的数据,要验证操作权限。
看个实际例子:
// app/actions.ts
'use server'
import { cookies } from 'next/headers'
import { z } from 'zod'
const DeletePostSchema = z.object({
postId: z.string().min(1),
})
export async function deletePost(formData: FormData) {
// 1. 验证输入
const rawData = {
postId: formData.get('postId'),
}
const result = DeletePostSchema.safeParse(rawData)
if (!result.success) {
return { success: false, error: '无效的请求' }
}
const { postId } = result.data
// 2. 身份认证:检查用户是否登录
const cookieStore = await cookies()
const sessionToken = cookieStore.get('session')?.value
if (!sessionToken) {
return { success: false, error: '请先登录' }
}
// 3. 获取当前用户
const currentUser = await getUserFromSession(sessionToken)
if (!currentUser) {
return { success: false, error: '会话已过期' }
}
// 4. 权限验证:检查这篇文章是否属于当前用户
const post = await getPost(postId)
if (!post) {
return { success: false, error: '文章不存在' }
}
if (post.authorId !== currentUser.id) {
return { success: false, error: '你没有权限删除这篇文章' }
}
// 5. 执行操作
await deletePostFromDB(postId)
return { success: true, message: '删除成功' }
}
这个例子演示了完整的安全检查流程:输入验证 → 身份认证 → 权限验证 → 执行操作。缺一不可。
还有个实用工具推荐:next-safe-action 库。它提供了中间件机制,可以统一处理验证、认证、错误处理:
import { createSafeActionClient } from 'next-safe-action'
// 创建一个带认证的 action 客户端
const actionClient = createSafeActionClient({
// 中间件:检查用户登录状态
async middleware() {
const session = await getSession()
if (!session) {
throw new Error('未登录')
}
return { userId: session.userId }
},
})
// 使用时自动带上认证检查
export const deletePost = actionClient
.schema(DeletePostSchema)
.action(async ({ parsedInput, ctx }) => {
const { postId } = parsedInput
const { userId } = ctx // 从中间件获取用户 ID
// 执行删除...
})
这样一来,所有需要认证的 Action 都复用同一套逻辑,代码清爽多了。
记住:Server Actions 不是黑魔法,它就是个 API 端点。该做的安全措施一个都不能少。
实战案例:带身份验证的表单
来个完整的例子,做个只有登录用户才能提交的评论表单:
// app/actions.ts
'use server'
import { cookies } from 'next/headers'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
const CommentSchema = z.object({
postId: z.string(),
content: z.string().min(1, '评论不能为空').max(500, '评论最多 500 字'),
})
export async function addComment(formData: FormData) {
// 1. 验证输入
const rawData = {
postId: formData.get('postId'),
content: formData.get('content'),
}
const result = CommentSchema.safeParse(rawData)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
}
}
// 2. 身份认证
const cookieStore = await cookies()
const sessionToken = cookieStore.get('session')?.value
if (!sessionToken) {
return {
success: false,
error: '请先登录后再评论',
}
}
const user = await getUserFromSession(sessionToken)
if (!user) {
return {
success: false,
error: '会话已过期,请重新登录',
}
}
// 3. 保存评论
const { postId, content } = result.data
await saveComment({
postId,
content,
authorId: user.id,
authorName: user.name,
createdAt: new Date(),
})
// 4. 重新验证页面缓存,让评论立即显示
revalidatePath(`/posts/${postId}`)
return {
success: true,
message: '评论成功',
}
}
客户端组件:
// app/posts/[id]/CommentForm.tsx
'use client'
import { useActionState } from 'react'
import { addComment } from '@/app/actions'
import { SubmitButton } from '@/components/SubmitButton'
export function CommentForm({ postId }: { postId: string }) {
const [state, formAction] = useActionState(addComment, {
success: false,
errors: {},
})
return (
<form action={formAction}>
{/* 隐藏字段传递 postId */}
<input type="hidden" name="postId" value={postId} />
<textarea
name="content"
placeholder="写下你的评论..."
rows={4}
required
/>
{state.errors?.content && (
<p className="error">{state.errors.content[0]}</p>
)}
{state.error && (
<p className="error">{state.error}</p>
)}
{state.success && (
<p className="success">{state.message}</p>
)}
<SubmitButton>发表评论</SubmitButton>
</form>
)
}
这个例子结合了前面讲的所有要点:
- Zod 验证输入
- 检查用户登录状态
- 使用
useActionState处理状态 - 用
revalidatePath刷新缓存 - 提交按钮带 Loading 状态
完整的表单处理流程,生产可用。
进阶技巧
传递额外参数
有时候你需要传递表单字段之外的参数。比如编辑文章时,除了表单内容,还要传文章 ID。
一个办法是用隐藏字段:
<input type="hidden" name="postId" value={postId} />
但还有更优雅的方式:用 JavaScript 的 bind 方法。
// app/actions.ts
'use server'
export async function updatePost(postId: string, formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
// 更新文章...
await updatePostInDB(postId, { title, content })
return { success: true }
}
客户端调用时:
// app/posts/[id]/edit/page.tsx
'use client'
import { updatePost } from '@/app/actions'
export default function EditPost({ postId }: { postId: string }) {
// 用 bind 绑定 postId 参数
const updatePostWithId = updatePost.bind(null, postId)
return (
<form action={updatePostWithId}>
<input type="text" name="title" required />
<textarea name="content" required />
<button type="submit">更新</button>
</form>
)
}
bind(null, postId) 的作用是创建一个新函数,把 postId 固定为第一个参数。这样表单提交时,FormData 会作为第二个参数传进去。
适用场景:编辑、删除等需要传 ID 的操作。
数据重新验证
Server Actions 处理完数据后,相关页面的缓存可能已经过时了。Next.js 提供了两个函数来刷新缓存:
1. revalidatePath
按路径刷新:
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
// 创建文章...
// 刷新首页的文章列表
revalidatePath('/')
// 刷新文章详情页
revalidatePath(`/posts/${newPostId}`)
return { success: true }
}
2. revalidateTag
按标签刷新(需要先在 fetch 时打标签):
// 获取数据时打标签
fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})
// Server Action 里刷新所有带 'posts' 标签的缓存
import { revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) {
// 创建文章...
revalidateTag('posts') // 刷新所有相关缓存
return { success: true }
}
啥时候用哪个?
- 路径固定、数量少 → 用
revalidatePath - 数据分散在多个页面 → 用
revalidateTag
我一般优先用 revalidatePath,简单直接。只有在一个操作影响很多页面时,才考虑用标签。
乐观更新
有些操作几乎不会失败,比如点赞、收藏。这种情况下,可以用乐观更新:先在 UI 上显示成功,后台再慢慢提交。
React 19 提供了 useOptimistic Hook:
'use client'
import { useOptimistic } from 'react'
import { likePost } from '@/app/actions'
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [optimisticLikes, setOptimisticLikes] = useOptimistic(initialLikes)
async function handleLike() {
// 立即更新 UI(乐观)
setOptimisticLikes(optimisticLikes + 1)
// 后台提交
await likePost(postId)
}
return (
<button onClick={handleLike}>
👍 {optimisticLikes}
</button>
)
}
用户点击按钮,数字立刻 +1,不用等服务端响应。体验丝滑。
但要注意:只在成功率极高的操作用这个。如果失败了,还得回滚 UI,反而更麻烦。
结论
说了这么多,总结三点:
-
Server Actions 简化了表单处理,但不是万能的。内部表单用它,对外 API 还是得用 Route Handlers。别一股脑全用 Server Actions。
-
安全性要自己保障。框架提供的只是基础防护,输入验证、身份认证、权限检查…该做的一个都不能少。别指望 Next.js 帮你搞定一切。
-
用户体验细节很重要。Loading 状态、错误提示、乐观更新…这些小细节决定了用户觉得你的应用是”还行”还是”真好用”。结合
useActionState和useFormStatus,把这些都处理好。
从最简单的表单开始试试吧。创个 Server Action,加上 Zod 验证,显示个 Loading,你就掌握 80% 的用法了。剩下的 20%(缓存刷新、乐观更新等),等用到的时候再查官方文档。
Next.js 和 React 都在快速迭代,Server Actions 的 API 可能还会变。记得关注官方文档的更新,别让这篇文章里的代码过时太快。
现在就去你的项目里试试吧。下次写表单提交的时候,也许你会发现,原来可以这么简单。
使用 Server Actions 处理表单的完整流程
从创建 Server Action 到添加验证、处理状态的完整步骤
⏱️ 预计耗时: 30 分钟
- 1
步骤1: 创建 Server Action
在 app/actions.ts 文件中创建 Server Action:
1. 文件级别标记:在文件顶部添加 'use server'
2. 定义函数:export async function actionName(formData: FormData)
3. 获取数据:使用 formData.get('fieldName') 获取表单字段
4. 返回结果:返回 { success: boolean, errors?: {}, message?: string } 格式
示例:
```typescript
'use server'
export async function signup(formData: FormData) {
const name = formData.get('name') as string
// 处理逻辑...
return { success: true, message: '注册成功' }
}
``` - 2
步骤2: 添加 Zod 验证
使用 Zod 进行服务端数据验证:
1. 安装 Zod:npm install zod
2. 定义 Schema:const SignupSchema = z.object({ name: z.string().min(2), email: z.string().email() })
3. 验证数据:const result = SignupSchema.safeParse(rawData)
4. 处理错误:if (!result.success) return { success: false, errors: result.error.flatten().fieldErrors }
关键点:
• safeParse 不会抛异常,返回 { success, data/error }
• flatten().fieldErrors 将错误转为 { field: ['error1'] } 格式
• 验证失败时返回结构化错误,客户端可展示 - 3
步骤3: 使用 useActionState 处理状态
在客户端组件中使用 useActionState:
1. 导入 Hook:import { useActionState } from 'react'
2. 定义初始状态:const initialState = { success: false, errors: {} }
3. 使用 Hook:const [state, formAction, isPending] = useActionState(action, initialState)
4. 绑定表单:<form action={formAction}>
5. 显示错误:{state.errors?.field && <p>{state.errors.field[0]}</p>}
6. 显示 Loading:<button disabled={isPending}>{isPending ? '提交中...' : '提交'}</button>
工作流程:
• 用户提交 → 调用 action → 返回结果 → state 更新 → 组件重新渲染 - 4
步骤4: 添加身份认证和权限验证
在 Server Action 中添加安全检查:
1. 输入验证:使用 Zod 验证所有输入
2. 身份认证:检查 session token
```typescript
const cookieStore = await cookies()
const sessionToken = cookieStore.get('session')?.value
if (!sessionToken) return { success: false, error: '请先登录' }
```
3. 权限验证:检查操作权限
```typescript
const post = await getPost(postId)
if (post.authorId !== currentUser.id) {
return { success: false, error: '无权限' }
}
```
4. 执行操作:验证通过后执行实际业务逻辑
记住:Server Actions 不是黑魔法,必须手动做安全检查 - 5
步骤5: 优化用户体验
添加 Loading 状态和错误处理:
1. 使用 useFormStatus(在按钮组件中):
```typescript
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>...</button>
}
```
2. 使用 revalidatePath 刷新缓存:
```typescript
import { revalidatePath } from 'next/cache'
revalidatePath('/posts')
```
3. 乐观更新(可选,用于高成功率操作):
```typescript
const [optimisticState, setOptimisticState] = useOptimistic(initialState)
```
最佳实践:
• 表单逻辑复杂 → 用 useActionState
• 独立按钮组件 → 用 useFormStatus
• 操作成功后 → 刷新相关页面缓存
常见问题
Server Actions 和 API Routes 有什么区别?什么时候用哪个?
Server Actions 安全吗?需要做哪些安全措施?
useActionState 和 useFormStatus 的区别是什么?
如何传递表单字段之外的参数?
表单提交后如何刷新页面数据?
什么时候使用乐观更新?
12 分钟阅读 · 发布于: 2025年12月19日 · 修改于: 2026年1月22日
相关文章
Next.js 电商实战:购物车与 Stripe 支付完整实现指南
Next.js 电商实战:购物车与 Stripe 支付完整实现指南
Next.js 文件上传完整指南:S3/七牛云预签名URL直传实战
Next.js 文件上传完整指南:S3/七牛云预签名URL直传实战
Next.js 单元测试实战:Jest + React Testing Library 完整配置指南

评论
使用 GitHub 账号登录后即可评论