在 Next.js App Router 架构下,表单处理方式发生了革命性变化。借助 React Server Actions,我们可以在不写一行客户端 JavaScript 的情况下,实现高性能、高安全性、具备完整用户体验的表单交互。
本文将带你从零开始,掌握 Next.js App Router 中表单开发的核心模式、最佳实践与高级技巧 ,并附上一个完整的登录表单实战示例。
一、为什么使用 Server Actions?
传统表单通常依赖客户端状态 + API 调用,存在以下问题:
- 需要手动管理 loading/error 状态
- 容易暴露 API 端点
- 验证逻辑分散在前后端
- SEO 友好性差(依赖 JS 执行)
而 Server Actions 的优势: ✅ 数据直接在服务端处理,无需暴露 API
✅ 自动 revalidate 缓存,保证数据一致性
✅ 支持渐进增强(无 JS 也能提交)
✅ 与 React 19 新特性深度集成
二、基础表单:最简实现
步骤 1:创建 Server Action
在 app/actions.ts
中定义服务端函数:
ts
// app/actions.ts
'use server'
export async function createInvoice(formData: FormData) {
const rawFormData = {
customerId: formData.get('customerId') as string,
amount: Number(formData.get('amount')),
status: formData.get('status') as string,
}
// 1. 数据验证(后文详述)
// 2. 写入数据库
// 3. revalidatePath('/invoices') 自动刷新缓存
console.log('提交数据:', rawFormData)
return { success: true }
}
⚠️ 关键:必须标注
'use server'
,表示该函数在服务端执行。
步骤 2:在组件中使用
tsx
// app/page.tsx
import { createInvoice } from '@/app/actions'
export default function InvoiceForm() {
return (
<form action={createInvoice} className="space-y-4">
<input name="customerId" placeholder="客户ID" required />
<input name="amount" type="number" placeholder="金额" required />
<select name="status">
<option value="pending">待支付</option>
<option value="paid">已支付</option>
</select>
<button type="submit">创建发票</button>
</form>
)
}
✅ 核心机制 :
Next.js 自动将 <form action={serverFunction}>
转换为服务端调用,formData
为原生 FormData
对象。
三、传递额外参数:bind
vs 隐藏字段
场景:编辑用户资料(需传入 userId
)
✅ 推荐方式:使用 bind
tsx
// Client Component
'use client'
import { updateUser } from './actions'
export function UserProfile({ userId }: { userId: string }) {
// 通过 bind 预填充第一个参数
const updateUserWithId = updateUser.bind(null, userId)
return (
<form action={updateUserWithId}>
<input name="name" />
<button type="submit">更新</button>
</form>
)
}
ts
// Server Action
'use server'
export async function updateUser(userId: string, formData: FormData) {
const name = formData.get('name') as string
// 更新 userId 对应的用户
}
⚠️ 替代方案:隐藏字段(不推荐)
tsx
<input type="hidden" name="userId" value={userId} />
缺点:
userId
会暴露在 HTML 中,存在安全风险。
四、表单验证:服务端 + 客户端协同
1. 服务端验证(必备)
使用 Zod 进行强类型验证:
ts
// app/actions.ts
import { z } from 'zod'
const schema = z.object({
email: z.string().email({ message: '邮箱格式无效' }),
password: z.string().min(8, { message: '密码至少8位' }),
})
export async function signup(formData: FormData) {
const validated = schema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
})
if (!validated.success) {
// 返回结构化错误
return {
errors: validated.error.flatten().fieldErrors,
}
}
// 创建用户...
return { success: true }
}
2. 显示错误:useActionState
将表单组件转为 Client Component 以支持状态管理:
tsx
// app/signup/page.tsx
'use client'
import { useActionState } from 'react'
import { signup } from '@/app/actions'
const initialState = {
errors: {} as Record<string, string[]>,
message: '',
}
export default function SignupForm() {
const [state, formAction] = useActionState(signup, initialState)
return (
<form action={formAction} className="max-w-md">
<div>
<input name="email" type="email" />
{state.errors.email && (
<p className="text-red-500">{state.errors.email[0]}</p>
)}
</div>
<div>
<input name="password" type="password" />
{state.errors.password && (
<p className="text-red-500">{state.errors.password[0]}</p>
)}
</div>
<button type="submit">注册</button>
</form>
)
}
💡
useActionState
会自动将state
作为第一个参数传给 Server Action。
五、加载状态:禁用按钮 & 加载指示器
方案 1:使用 useActionState
的 pending
tsx
const [state, formAction, isPending] = useActionState(signup, initialState)
<button type="submit" disabled={isPending}>
{isPending ? '提交中...' : '注册'}
</button>
方案 2:使用 useFormStatus
(推荐)
创建独立的提交按钮组件:
tsx
// app/components/SubmitButton.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button type="submit" disabled={pending}>
{pending ? '处理中...' : '提交'}
</button>
)
}
在表单中使用:
tsx
<form action={createInvoice}>
{/* 表单字段 */}
<SubmitButton />
</form>
✅ 优势:自动绑定到最近的
<form>
,无需传递状态。
六、完整实战:Next.js 登录表单
下面是一个生产级登录表单示例,包含验证、错误提示、加载状态、安全重定向。
1. 定义 Server Action(app/actions/auth.ts
)
ts
// app/actions/auth.ts
'use server'
import { z } from 'zod'
import { redirect } from 'next/navigation'
import { signIn } from '@/lib/auth' // 假设你有自己的认证逻辑
const LoginSchema = z.object({
email: z.string().email({ message: '请输入有效的邮箱' }),
password: z.string().min(6, { message: '密码至少6位' }),
})
export async function login(prevState: any, formData: FormData) {
const validatedFields = LoginSchema.safeParse({
email: formData.get('email'),
password: formData.get('password'),
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: '缺少必填字段或格式错误',
}
}
const { email, password } = validatedFields.data
try {
await signIn(email, password)
} catch (error) {
if ((error as Error).message.includes('Invalid credentials')) {
return {
errors: {},
message: '邮箱或密码错误',
}
}
return {
errors: {},
message: '登录失败,请稍后重试',
}
}
// 登录成功,重定向到仪表盘
redirect('/dashboard')
}
2. 创建登录页面(app/login/page.tsx
)
tsx
// app/login/page.tsx
'use client'
import { useActionState } from 'react'
import { login } from '@/app/actions/auth'
import { SubmitButton } from '@/app/components/SubmitButton'
const initialState = {
message: '',
errors: {} as Record<string, string[]>,
}
export default function LoginPage() {
const [state, formAction] = useActionState(login, initialState)
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<form action={formAction} className="w-full max-w-md space-y-6">
<h1 className="text-2xl font-bold text-center">登录</h1>
{state.message && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded">
{state.message}
</div>
)}
<div>
<label htmlFor="email" className="block text-sm font-medium">
邮箱
</label>
<input
id="email"
name="email"
type="email"
required
className="mt-1 block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
{state.errors.email && (
<p className="mt-1 text-sm text-red-600">{state.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium">
密码
</label>
<input
id="password"
name="password"
type="password"
required
className="mt-1 block w-full px-3 py-2 border rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
{state.errors.password && (
<p className="mt-1 text-sm text-red-600">{state.errors.password[0]}</p>
)}
</div>
<SubmitButton />
</form>
</div>
)
}
3. 提交按钮组件(app/components/SubmitButton.tsx
)
tsx
// app/components/SubmitButton.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton() {
const { pending } = useFormStatus()
return (
<button
type="submit"
disabled={pending}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{pending ? '登录中...' : '登录'}
</button>
)
}
4. 安全说明
- 所有验证在服务端完成,防止绕过
- 密码不会回显到客户端
- 成功后使用
redirect()
跳转,避免刷新重复提交 - 错误信息不泄露内部细节(如"用户不存在")
七、高级技巧
1. 乐观更新(Optimistic UI)
在服务端响应前先更新 UI:
tsx
'use client'
import { useOptimistic } from 'react'
type Message = { id: string; text: string }
export function Chat({ messages }: { messages: Message[] }) {
const [optimisticMessages, addOptimistic] = useOptimistic(messages,
(state, newMessage: string) => [...state, { id: Date.now().toString(), text: newMessage }]
)
const handleSubmit = async (formData: FormData) => {
const text = formData.get('message') as string
addOptimistic(text) // 立即更新 UI
await sendMessage(text) // 实际发送
}
return (
<div>
{optimisticMessages.map(msg => <div key={msg.id}>{msg.text}</div>)}
<form action={handleSubmit}>
<input name="message" />
<button type="submit">发送</button>
</form>
</div>
)
}
2. 嵌套表单操作
在同一个表单中触发多个 Server Action:
tsx
<form>
<input name="title" />
{/* 保存草稿 */}
<button formAction={saveDraft}>保存草稿</button>
{/* 发布文章 */}
<button formAction={publishPost}>发布</button>
</form>
3. 程序化提交(如 Cmd+Enter)
tsx
<textarea
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
e.currentTarget.form?.requestSubmit()
}
}}
/>
八、安全与性能最佳实践
实践 | 说明 |
---|---|
始终服务端验证 | 客户端验证可绕过 |
使用 Zod 等库 | 避免手写验证逻辑 |
限制文件上传 | 结合 next-safe-action |
启用 revalidate | revalidatePath('/dashboard') 保证数据新鲜 |
避免敏感数据回显 | 错误信息不要泄露内部结构 |
九、总结:Next.js 表单开发范式
能力 | 实现方式 |
---|---|
基础提交 | <form action={serverAction}> |
参数传递 | bind() |
验证与错误 | Zod + useActionState |
加载状态 | useFormStatus |
乐观更新 | useOptimistic |
多操作 | formAction 属性 |
Server Actions 不仅简化了表单开发,更代表了 "服务端优先" 的现代 Web 开发哲学。掌握它,你就能构建出更快、更安全、更易维护的 Next.js 应用。