Next.js: Server Actions 入门
最后更新:2026-08-26
Server Actions 是 Next.js 的"超能力"——它让浏览器直接调用服务端函数,无需手动构建 API 端点。
1. 你将学到
'use server'指令与 Server Action 的定义方式<form action={}>的表单处理模式- 渐进增强:无 JavaScript 也能提交表单
revalidatePath()提交后刷新页面数据- Zod 集成实现表单参数校验
2. 一个全栈开发者的真实故事
(1) 痛点:一个"简单"的表单提交要写 80 行
Bob 正在 TaskFlow 中添加"创建项目"功能。传统做法需要:
- 写一个
POST /api/projectsAPI Route(20 行) - 写一个
fetch()客户端调用(10 行) - 处理 CSRF Token(10 行)
- 处理 loading/error/成功状态(20 行)
- 刷新页面数据(10 行)
- 校验输入(10 行)
- 总计 ~80 行代码
Bob 叹气:"我只是要提交一个表单而已。"
(2) Server Actions 的解法
用一个
'use server'函数直接处理表单——零客户端 JS 开销,零 API Route。
// app/projects/page.tsx
export default function ProjectsPage() {
return (
<form action={async (formData: FormData) => {
'use server'
const name = formData.get('name') as string
await db.project.create({ data: { name } })
revalidatePath('/projects')
}}>
<input name="name" required placeholder="Project name" />
<button type="submit">Create Project</button>
</form>
)
}
(3) 收益
| 维度 | 传统 API Route | Server Actions |
|---|---|---|
| 代码行数 | 80 行 | 15 行 |
| API Route 文件 | ✅ 需要额外文件 | ❌ 不需要 |
| JavaScript 依赖 | ✅ 必须 | ❌ 渐进增强 |
| CSRF 保护 | ❌ 需手动 | ✅ 内置 |
| 加载状态 | ✅ 需手动 state | ✅ useActionState |
| 类型安全 | ❌ 松散 | ✅ 完整的 TS 类型 |
3. Server Actions 的核心工作流程
sequenceDiagram
participant Browser as Browser
participant RSC as RSC Payload
participant SA as Server Action
participant DB as Database
Browser->>SA: <form action={action}> 提交
SA->>SA: 编译时标记 'use server'
SA->>DB: 直接数据库写入
DB-->>SA: 成功
SA->>SA: revalidatePath() / revalidateTag()
SA-->>Browser: 返回 RSC Payload(新 UI)
Note over Browser: 无需整页刷新
| 参与者 | 角色 | 说明 |
|---|---|---|
| Browser | 表单提交者 | 通过 <form action> 或 JS 调用 |
| RSC Payload | 传输协议 | 浏览器与服务端之间的序列化桥接 |
| Server Action | 处理函数 | 'use server' 标记的异步函数 |
| Database | 数据持久化 | Prisma / Drizzle 或外部 API |
4. 三种 Server Action 定义方式
Server Action 有两种定义位置和一种 React 19 API 集成:
| 方式 | 位置 | 适用场景 | 代码示例 |
|---|---|---|---|
| 单独文件 | app/actions.ts |
多个页面共享 | export async function createProject(...) |
| 内联 | 组件内 <form action={async ...}> |
简单操作 | 'use server' 在函数体内 |
| useActionState | Client Component | 需要复杂状态管理 | useActionState(action, initialState) |
(1) 单独文件模式(推荐)
// app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
import { db } from '@/lib/db'
export async function createProject(formData: FormData) {
const name = formData.get('name') as string
const description = formData.get('description') as string
await db.project.create({
data: { name, description }
})
revalidatePath('/projects')
}
export async function deleteProject(id: number) {
await db.project.delete({ where: { id } })
revalidatePath('/projects')
}
// app/projects/page.tsx
import { createProject } from '@/app/actions'
export default function ProjectsPage() {
return (
<form action={createProject}>
<input name="name" required />
<input name="description" />
<button type="submit">Create</button>
</form>
)
}
(2) 内联模式(简单场景)
// app/inline-demo/page.tsx — 内联 Server Action
export default function InlineDemoPage() {
return (
<form action={async (formData: FormData) => {
'use server'
const message = formData.get('message') as string
console.log('Received:', message)
// 直接处理,无需额外文件
}}>
<input name="message" required />
<button type="submit">Send</button>
</form>
)
}
(3) useActionState 模式(推荐带状态表单)
// app/todos/use-action-state.tsx
'use client'
import { useActionState } from 'react'
async function addTodo(prevState: string[], formData: FormData) {
'use server'
const todo = formData.get('todo') as string
await db.todo.create({ data: { title: todo } })
revalidateTag('todos')
return [...prevState, todo]
}
export default function TodoForm() {
const [todos, formAction, isPending] = useActionState(addTodo, [])
return (
<form action={formAction}>
<input name="todo" required disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Adding...' : 'Add Todo'}
</button>
<ul>{todos.map((t, i) => <li key={i}>{t}</li>)}</ul>
</form>
)
}
▶ 示例:三种定义方式对比(难度⭐)
// app/actions-comparison/page.tsx
import { createTodoInline, createTodoAction } from './actions'
export default function ActionsComparisonPage() {
return (
<div>
<h1>Server Actions — 3 Ways</h1>
{/* 方式 1:单独文件 */}
<section>
<h2>1. Separate file</h2>
<form action={createTodoAction}>
<input name="title" required />
<button type="submit">Create</button>
</form>
</section>
{/* 方式 2:内联 */}
<section>
<h2>2. Inline</h2>
<form action={async (formData: FormData) => {
'use server'
const title = formData.get('title') as string
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title, completed: false })
})
}}>
<input name="title" required />
<button type="submit">Create</button>
</form>
</section>
{/* 方式 3:Client Component 调用 */}
<section>
<h2>3. UseActionState (see below)</h2>
<TodoFormWithState />
</section>
</div>
)
}
// app/actions-comparison/TodoFormWithState.tsx
'use client'
import { useActionState } from 'react'
import { createTodoAction } from './actions'
export default function TodoFormWithState() {
const [state, action, pending] = useActionState(createTodoAction, null)
return (
<form action={action}>
<input name="title" required disabled={pending} />
<button type="submit">{pending ? 'Creating...' : 'Create'}</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && <p style={{ color: 'green' }}>Todo created!</p>}
</form>
)
}
// app/actions-comparison/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function createTodoAction(prevState: any, formData: FormData) {
const title = formData.get('title') as string
if (!title || title.length < 2) {
return { error: 'Title must be at least 2 characters' }
}
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title, completed: false })
})
revalidatePath('/actions-comparison')
return { success: true }
}
export async function createTodoInline(formData: FormData) {
const title = formData.get('title') as string
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title, completed: false })
})
revalidatePath('/actions-comparison')
}
5. 表单 action 与渐进增强
Server Actions 最重要的特性是渐进增强(Progressive Enhancement)——即使浏览器禁用 JavaScript,表单仍然能正常提交。
(1) 原生 HTML 表单行为
// app/progressive/page.tsx — 渐进增强:JS 禁用也能用
export default function ProgressivePage() {
return (
<form action={async (formData: FormData) => {
'use server'
const email = formData.get('email') as string
const message = formData.get('message') as string
await sendEmail({ to: email, body: message })
revalidatePath('/progressive')
}}>
<label>Email: <input name="email" type="email" required /></label>
<label>Message: <textarea name="message" required /></label>
<button type="submit">Send</button>
</form>
)
}
| 状态 | HTML 原生 | + JavaScript 增强 |
|---|---|---|
| 提交方式 | POST 到当前 URL |
使用 fetch + RSC Payload |
| 页面刷新 | 整页刷新 | 没有整页刷新(软导航) |
| 用户体验 | 传统表单提交 | 无闪烁提交 |
| 功能 | ✅ 完整可用 | ✅ 增强体验 |
(2) 使用 action 属性 vs onSubmit
| 方式 | HTML 要求 | JS 要求 | 渐进增强 |
|---|---|---|---|
<form action={serverAction}> |
✅ 无需 JS | ✅ 增强 | ✅ 是 |
<form onSubmit={handler}> |
❌ 需要 preventDefault |
✅ 需要 | ❌ 否 |
▶ 示例:JS 禁用场景测试(难度⭐⭐)
// app/no-js-demo/page.tsx — 测试渐进增强
export default function NoJsDemoPage() {
return (
<form action={async (formData: FormData) => {
'use server'
const item = formData.get('item') as string
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title: item, completed: false })
})
revalidatePath('/no-js-demo')
}}>
<input name="item" required placeholder="Enter todo item" />
<button type="submit">Add Todo</button>
</form>
)
}
测试方法:
- 正常使用:输入文本 → 点击提交 → 列表更新
- JS 禁用:DevTools → Settings → Disable JavaScript → 刷新 → 提交表单 → 仍然生效
6. Zod 校验集成
Server Actions 应该始终对输入数据进行校验。Zod 是 TypeScript 生态最流行的校验库。
(1) 基本校验模式
// app/actions/schema.ts
import { z } from 'zod'
export const projectSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters').max(100),
description: z.string().max(500).optional(),
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format').optional(),
})
// app/actions/create.ts
'use server'
import { revalidatePath } from 'next/cache'
import { projectSchema } from './schema'
export async function createProject(prevState: any, formData: FormData) {
const validated = projectSchema.safeParse({
name: formData.get('name'),
description: formData.get('description'),
dueDate: formData.get('dueDate'),
})
if (!validated.success) {
return { errors: validated.error.flatten().fieldErrors }
}
try {
await db.project.create({ data: validated.data })
revalidatePath('/projects')
return { success: true }
} catch (err) {
return { error: 'Failed to create project' }
}
}
(2) 客户端显示校验错误
// app/projects/CreateProjectForm.tsx
'use client'
import { useActionState } from 'react'
import { createProject } from '@/app/actions/create'
export function CreateProjectForm() {
const [state, action, pending] = useActionState(createProject, null)
return (
<form action={action}>
<div>
<input name="name" required placeholder="Project name" disabled={pending} />
{state?.errors?.name && <p style={{ color: 'red' }}>{state.errors.name[0]}</p>}
</div>
<div>
<textarea name="description" placeholder="Description" disabled={pending} />
</div>
<div>
<input name="dueDate" type="date" disabled={pending} />
{state?.errors?.dueDate && <p style={{ color: 'red' }}>{state.errors.dueDate[0]}</p>}
</div>
<button type="submit" disabled={pending}>
{pending ? 'Creating...' : 'Create Project'}
</button>
{state?.success && <p style={{ color: 'green' }}>Project created!</p>}
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
</form>
)
}
▶ 示例:完整 Zod 校验表单(难度⭐⭐)
// app/zod-demo/page.tsx — Zod 校验 + Server Actions
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
const contactSchema = z.object({
name: z.string().min(2),
email: z.string().email('Invalid email'),
message: z.string().min(10, 'Message too short').max(1000),
})
export default function ZodDemoPage() {
return (
<form action={async (formData: FormData) => {
'use server'
const validated = contactSchema.safeParse({
name: formData.get('name'),
email: formData.get('email'),
message: formData.get('message'),
})
if (!validated.success) {
return { errors: validated.error.flatten().fieldErrors }
}
console.log('Contact form submitted:', validated.data)
revalidatePath('/zod-demo')
return { success: true }
}}>
<div style={{ marginBottom: 12 }}>
<label>Name: <input name="name" required /></label>
</div>
<div style={{ marginBottom: 12 }}>
<label>Email: <input name="email" type="email" required /></label>
</div>
<div style={{ marginBottom: 12 }}>
<label>Message: <textarea name="message" required /></label>
</div>
<button type="submit">Submit Contact</button>
</form>
)
}
▶ 示例:revalidatePath 刷新列表(难度⭐)
// app/todos/page.tsx — 提交后刷新列表
import { revalidatePath } from 'next/cache'
export default async function TodosPage() {
const todos = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5', {
next: { tags: ['todos'] }
}).then(r => r.json())
return (
<div>
<h1>Todos</h1>
<ul>{todos.map((t: any) => <li key={t.id}>{t.title}</li>)}</ul>
<form action={async (formData: FormData) => {
'use server'
const title = formData.get('title') as string
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title, completed: false })
})
revalidatePath('/todos')
}}>
<input name="title" required />
<button type="submit">Add</button>
</form>
</div>
)
}
▶ 示例:使用 bind 传递额外参数(难度⭐⭐)
Server Action 可以配合 <form> 的 action 属性和 .bind() 方法传递额外的参数。
// app/bind-demo/page.tsx
import { revalidatePath } from 'next/cache'
export default async function BindDemoPage() {
const todos = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=5', {
next: { tags: ['bind-todos'] }
}).then(r => r.json())
return (
<div>
<h1>Todo List with Bind</h1>
<ul>{todos.map((t: any) => (
<li key={t.id}>
{t.title}
<form action={completeTodo.bind(null, t.id)} style={{ display: 'inline' }}>
<button type="submit">{t.completed ? '✅' : '⬜'}</button>
</form>
</li>
))}</ul>
</div>
)
}
async function completeTodo(id: number, formData: FormData) {
'use server'
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH', body: JSON.stringify({ completed: true })
})
revalidatePath('/bind-demo')
}
7. 完整示例:任务管理系统
// app/tasks/page.tsx — Server Actions 综合示例
import { revalidatePath } from 'next/cache'
import { z } from 'zod'
// ======== Schema ========
const taskSchema = z.object({
title: z.string().min(1, 'Title required').max(200),
priority: z.enum(['low', 'medium', 'high']),
assignee: z.string().min(1, 'Assignee required'),
})
// ======== 任务列表 ========
export default async function TasksPage() {
const tasks = await fetch('https://jsonplaceholder.typicode.com/todos?_limit=10', {
next: { tags: ['tasks'] }
}).then(r => r.json())
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
<h1>Task Manager</h1>
<AddTaskForm />
<div style={{ marginTop: 24 }}>
{tasks.map((t: any) => (
<div key={t.id} style={{ border: '1px solid #ddd', borderRadius: 8, padding: 12, marginBottom: 8 }}>
<span>{t.title}</span>
<form action={deleteTask} style={{ display: 'inline', marginLeft: 12 }}>
<input type="hidden" name="id" value={t.id} />
<button type="submit" style={{ color: 'red' }}>Delete</button>
</form>
</div>
))}
</div>
</div>
)
}
// ======== 新增任务表单 ========
function AddTaskForm() {
return (
<form action={createTask} style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input name="title" required placeholder="Task title" />
<select name="priority" defaultValue="medium">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<input name="assignee" required placeholder="Assignee" />
<button type="submit">Add Task</button>
</form>
)
}
// ======== Server Actions ========
async function createTask(formData: FormData) {
'use server'
const validated = taskSchema.safeParse({
title: formData.get('title'),
priority: formData.get('priority'),
assignee: formData.get('assignee'),
})
if (!validated.success) {
console.error('Validation failed:', validated.error.flatten())
return
}
await fetch('https://jsonplaceholder.typicode.com/todos', {
method: 'POST', body: JSON.stringify({ title: validated.data.title, completed: false })
})
revalidatePath('/tasks')
}
async function deleteTask(formData: FormData) {
'use server'
const id = formData.get('id') as string
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, { method: 'DELETE' })
revalidatePath('/tasks')
}
❓ 常见问题
useActionState 只能在 Client Component 中使用吗?useActionState 是 React 19 的客户端 Hook,需要 'use client'。它在客户端管理表单状态(pending、error、success)。纯 Server Component 可以使用 <form action={async} > 内联模式。'use server' 是在编译时标记的独立模块函数——它不能访问闭包中的变量。如果需要使用组件的 props 或 state,必须通过 formData 传递,或在单独的 actions.ts 文件中定义。formData 或 JSON 序列化传递。<form action={action}> 中,返回值被忽略。要获取返回值,需使用 useActionState(action, initialState) 或 startTransition + 显式 callAction()。推荐使用 useActionState 管理返回值。📖 小节
- Server Action 通过
'use server'指令定义,支持单独文件、内联、useActionState 三种方式 <form action={action}>是最推荐的表单提交方式,天然支持渐进增强- 渐进增强使表单在 JavaScript 禁用时仍然能通过原生 POST 提交
revalidatePath()和revalidateTag()在 Server Action 中用于提交后刷新缓存数据- Zod 集成实现 Server Action 参数的类型安全校验
- Server Actions 内置 CSRF 保护,无需手动添加 Token
- 使用
useActionState(action, initialState)管理 pending / 返回值 / 错误状态
📝 作业
-
基础题(⭐):创建一个
app/guestbook/page.tsx留言板,使用内联 Server Action 处理表单提交,将留言内容通过 console.log 输出(模拟数据库写入),提交后revalidatePath刷新页面。 -
进阶题(⭐⭐):在
app/todos-zod/page.tsx中实现带 Zod 校验的任务管理系统。Schema 要求:title(1-100 字符)、priority(枚举 low/medium/high)、dueDate(可选,YYYY-MM-DD 格式)。在表单下方显示具体的字段级校验错误。 -
挑战题(⭐⭐⭐):构建一个完整的"项目创建"模块:
app/projects/create/page.tsx包含多字段表单(名称、描述、截止日期、团队成员列表),app/actions/projects.ts中定义 CRUD Server Actions,使用useActionState在客户端管理提交状态(success/error/pending),支持图片 URL 输入并预览。