Next.js: Server Actions 进阶
最后更新:2026-08-26
2. 一个前端工程师的真实故事
(1) 痛点:上传文件要 8 秒,用户以为页面卡死了
Diana 正在开发 TaskFlow 的"附件上传"功能。用户选择一个 5 MB 的图片后:
- 前端用
fetch发送到/api/upload(2 秒上传) - 服务端用
sharp处理缩略图(3 秒) - 调用
revalidateTag刷新附件列表(0.5 秒) - 重定向回详情页(0.5 秒)
- 总计 ~6 秒
更糟的是,这 6 秒里没有任何 UI 反馈——用户以为页面卡死,会重复点击提交。
(2) Server Actions 的解法
用
useOptimistic立即在 UI 中显示"虚拟"附件 + Server Action 统一处理上传 + try-catch 错误捕获。
// app/tasks/[id]/Attachments.tsx — 乐观更新 + 文件上传
'use client'
import { useOptimistic, useActionState } from 'react'
import { uploadAttachment } from './actions'
export function Attachments({ taskId, initialFiles }: Props) {
const [optimisticFiles, addOptimistic] = useOptimistic(
initialFiles,
(state, newFile: File) => [...state, { id: 'pending', name: newFile.name, url: URL.createObjectURL(newFile), status: 'uploading' }]
)
const [error, formAction, pending] = useActionState(uploadAttachment, null)
return (
<form action={formAction}>
<input type="hidden" name="taskId" value={taskId} />
<input type="file" name="file" onChange={e => {
const file = e.target.files?.[0]
if (file) addOptimistic(file) // 立即显示在 UI
}} />
<button type="submit" disabled={pending}>Upload</button>
{error && <p style={{ color: 'red' }}>{error}</p>}
<ul>{optimisticFiles.map(f => <li key={f.id}>{f.name} {f.status === 'uploading' ? '⏳' : '✅'}</li>)}</ul>
</form>
)
}
(3) 收益
| 维度 | 传统 API Route | Server Action |
|---|---|---|
| 用户感知延迟 | 6 秒(无反馈) | 即时(乐观更新) |
| 代码行数 | 120 行(API + 客户端 + 校验) | 45 行 |
| 错误处理 | 需手动 try-catch 全链路 | 集中式 useActionState |
| 文件大小校验 | 客户端 + 服务端分开 | 统一在 Server Action |
3. 三模式错误处理
(1) useActionState 模式(推荐)
// app/error-demo/use-action-state.tsx
'use client'
import { useActionState } from 'react'
async function submitOrder(prevState: any, formData: FormData) {
'use server'
try {
const quantity = Number(formData.get('quantity'))
if (quantity < 1) throw new Error('Quantity must be >= 1')
if (quantity > 100) throw new Error('Quantity exceeds limit')
await db.order.create({ data: { quantity } })
revalidateTag('orders')
return { success: true, message: 'Order created' }
} catch (err: any) {
return { success: false, error: err.message }
}
}
export default function OrderForm() {
const [state, action, pending] = useActionState(submitOrder, null)
return (
<form action={action}>
<input type="number" name="quantity" min={1} disabled={pending} />
<button type="submit" disabled={pending}>{pending ? 'Submitting...' : 'Submit'}</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && <p style={{ color: 'green' }}>{state.message}</p>}
</form>
)
}
(2) startTransition 模式
// app/error-demo/transition.tsx
'use client'
import { useTransition } from 'react'
import { submitFeedback } from './actions'
export function FeedbackForm() {
const [isPending, startTransition] = useTransition()
return (
<form onSubmit={e => {
e.preventDefault()
const form = e.currentTarget
const data = new FormData(form)
startTransition(async () => {
try {
await submitFeedback(data)
form.reset()
} catch (err) {
alert(`Error: ${err}`)
}
})
}}>
<textarea name="feedback" required disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Sending...' : 'Send Feedback'}
</button>
</form>
)
}
(3) try-catch 模式(Server Action 内)
// app/actions/robust-action.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function robustAction(formData: FormData) {
const id = formData.get('id') as string
// 1. 参数校验
if (!id || isNaN(Number(id))) {
throw new Error('Invalid ID')
}
// 2. 业务逻辑 + 错误捕获
try {
await db.item.update({ where: { id: Number(id) }, data: { status: 'processed' } })
revalidatePath('/items')
return { ok: true }
} catch (dbError) {
console.error('Database error:', dbError)
throw new Error('Failed to update item in database')
}
}
▶ 示例:错误处理三模式对比(难度⭐⭐)
// app/error-comparison/page.tsx
import { revalidatePath } from 'next/cache'
// 模式 1:Server Action 内 try-catch + 返回值
async function createItem(formData: FormData) {
'use server'
try {
const name = formData.get('name') as string
if (!name || name.length < 2) return { error: 'Name too short' }
await fetch('https://jsonplaceholder.typicode.com/posts', { method: 'POST', body: JSON.stringify({ title: name }) })
revalidatePath('/error-comparison')
return { success: true }
} catch (err) {
return { error: 'Network error' }
}
}
export default function ErrorComparisonPage() {
return (
<div style={{ display: 'grid', gap: 32, padding: 24 }}>
<section>
<h2>Pattern 1: Server Action return state</h2>
<form action={createItem}>
<input name="name" required />
<button type="submit">Create</button>
</form>
</section>
</div>
)
}
4. useOptimistic 乐观更新
useOptimistic 是 React 19 的新 Hook,允许在 Server Action 完成之前立即更新 UI,然后在实际结果返回时自动回滚或确认。
sequenceDiagram
participant User
participant UI as Client UI
participant SA as Server Action
participant DB as Database
User->>UI: Click "Complete Task"
UI->>UI: useOptimistic → 立即变灰+打勾
UI->>SA: 调用 Server Action
SA->>DB: 更新数据库
DB-->>SA: 成功
SA-->>UI: 返回结果
UI->>UI: 与实际状态对比 → 确认或回滚
| 状态 | UI 展示 | 实际数据 | 用户感知 |
|---|---|---|---|
| 乐观更新 | ✅ 已完成(打勾) | ⏳ 处理中 | 即时 |
| 服务器确认 | ✅ 已完成 | ✅ 已完成 | 无变化 |
| 服务器拒绝 | ❌ 未完成(回滚) | ❌ 未完成 | 回滚动画 |
▶ 示例:乐观更新任务状态(难度⭐⭐⭐)
// app/todos/optimistic-list.tsx
'use client'
import { useOptimistic } from 'react'
import { revalidatePath } from 'next/cache'
type Todo = { id: number; title: string; completed: boolean }
export function OptimisticTodoList({ todos: initialTodos }: { todos: Todo[] }) {
const [todos, addOptimistic] = useOptimistic(
initialTodos,
(state, updatedTodo: Todo) =>
state.map(t => t.id === updatedTodo.id ? { ...t, completed: !t.completed } : t)
)
return (
<ul>{todos.map(todo => (
<li key={todo.id} style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.title}
<form action={async (formData: FormData) => {
'use server'
const id = Number(formData.get('id'))
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH', body: JSON.stringify({ completed: true })
})
revalidatePath('/todos/optimistic')
}} onSubmit={e => {
// 乐观更新:表单提交前立即更新 UI
addOptimistic({ ...todo, completed: !todo.completed })
}}>
<input type="hidden" name="id" value={todo.id} />
<button type="submit">{todo.completed ? 'Undo' : 'Complete'}</button>
</form>
</li>
))}</ul>
)
}
5. 文件上传处理
Server Actions 可以通过 formData 直接接收文件,服务端处理文件流,支持大小校验、类型检查、存储处理。
(1) 服务端文件处理
// app/actions/upload.ts
'use server'
import { revalidateTag } from 'next/cache'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'application/pdf']
const MAX_SIZE = 5 * 1024 * 1024 // 5 MB
export async function uploadAvatar(prevState: any, formData: FormData) {
const file = formData.get('avatar') as File | null
if (!file) return { error: 'No file selected' }
// 类型校验
if (!ALLOWED_TYPES.includes(file.type)) {
return { error: 'Invalid file type. Allowed: JPEG, PNG, WebP, PDF' }
}
// 大小校验
if (file.size > MAX_SIZE) {
return { error: `File too large. Max size: ${MAX_SIZE / 1024 / 1024} MB` }
}
try {
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name.replace(/\s/g, '_')}`
const path = join('public/uploads', filename)
await writeFile(path, buffer)
// 保存到数据库
await db.avatar.create({ data: { filename, path: `/uploads/${filename}` } })
revalidateTag('avatars')
return { success: true, url: `/uploads/${filename}` }
} catch (err) {
return { error: 'Failed to upload file' }
}
}
(2) 客户端上传表单
// app/profile/avatar-upload.tsx
'use client'
import { useActionState } from 'react'
import { uploadAvatar } from '@/app/actions/upload'
export function AvatarUpload() {
const [state, action, pending] = useActionState(uploadAvatar, null)
return (
<form action={action}>
<input type="file" name="avatar" accept="image/jpeg,image/png,image/webp" disabled={pending} />
<button type="submit" disabled={pending}>
{pending ? 'Uploading...' : 'Upload Avatar'}
</button>
{state?.error && <p style={{ color: 'red' }}>{state.error}</p>}
{state?.success && state?.url && (
<div>
<p style={{ color: 'green' }}>Upload successful!</p>
<img src={state.url} alt="avatar" style={{ width: 100, height: 100, borderRadius: '50%' }} />
</div>
)}
</form>
)
}
(3) 多文件上传
// app/actions/multi-upload.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function uploadGallery(prevState: any, formData: FormData) {
const files = formData.getAll('photos') as File[]
if (files.length === 0) return { error: 'No files selected' }
if (files.length > 10) return { error: 'Max 10 files allowed' }
const uploaded: string[] = []
for (const file of files) {
if (file.size > 5 * 1024 * 1024) continue
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name}`
await writeFile(join('public/uploads', filename), buffer)
uploaded.push(`/uploads/${filename}`)
}
revalidateTag('gallery')
return { success: true, urls: uploaded }
}
▶ 示例:完整文件上传 + 预览(难度⭐⭐⭐)
// app/upload-demo/page.tsx — 文件上传综合
import { revalidatePath } from 'next/cache'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import fs from 'node:fs'
export default async function UploadDemoPage() {
// 读取已有文件列表
const uploadDir = join(process.cwd(), 'public', 'uploads')
const files = fs.existsSync(uploadDir) ? fs.readdirSync(uploadDir) : []
return (
<div style={{ maxWidth: 600, margin: '0 auto', padding: 24 }}>
<h1>File Upload Demo</h1>
<form action={async (formData: FormData) => {
'use server'
const file = formData.get('file') as File
if (!file) return
if (file.size > 2 * 1024 * 1024) {
return { error: 'File too large (max 2MB)' }
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
const filename = `${Date.now()}-${file.name}`
await writeFile(join('public/uploads', filename), buffer)
revalidatePath('/upload-demo')
}}>
<input type="file" name="file" required />
<button type="submit">Upload</button>
</form>
<div style={{ marginTop: 24 }}>
<h2>Uploaded Files ({files.length})</h2>
<ul>{files.map(f => (
<li key={f}>
<a href={`/uploads/${f}`} target="_blank">{f}</a>
</li>
))}</ul>
</div>
</div>
)
}
6. DB 写入 + 缓存失效 + 重定向事务
生产级的 Server Action 通常包含三个步骤:写入数据库 → 清除缓存 → 重定向用户。
sequenceDiagram
participant User
participant Action as Server Action
participant DB as Database
participant Cache as Cache Layer
User->>Action: 提交表单
Action->>Action: Zod 校验
Action->>DB: INSERT / UPDATE
DB-->>Action: 成功
Action->>Cache: revalidateTag / revalidatePath
Action->>User: redirect 到新页面/详情页
| 步骤 | API | 说明 |
|---|---|---|
| 写入 | db.create() / db.update() |
Prisma / Drizzle 数据库操作 |
| 缓存失效 | revalidateTag() / revalidatePath() |
确保列表页显示最新数据 |
| 重定向 | redirect() |
提交后跳转到详情页或列表页 |
▶ 示例:完整事务模式(难度⭐⭐⭐)
// app/posts/create/page.tsx — 写 + 缓存失效 + 重定向
import { revalidateTag } from 'next/cache'
import { redirect } from 'next/navigation'
import { z } from 'zod'
const postSchema = z.object({
title: z.string().min(5).max(200),
content: z.string().min(20),
published: z.coerce.boolean().default(false),
})
export default function CreatePostPage() {
return (
<form action={async (formData: FormData) => {
'use server'
// 1. 校验
const validated = postSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published'),
})
if (!validated.success) return { errors: validated.error.flatten().fieldErrors }
// 2. 写入数据库
const post = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
body: JSON.stringify({
title: validated.data.title,
body: validated.data.content,
userId: 1,
})
}).then(r => r.json())
// 3. 缓存失效
revalidateTag('posts')
revalidatePath('/posts')
// 4. 重定向到新文章
redirect(`/posts/${post.id}`)
}}>
<div><input name="title" required placeholder="Post title" /></div>
<div><textarea name="content" required placeholder="Content" rows={10} /></div>
<div><label><input name="published" type="checkbox" value="true" /> Published</label></div>
<button type="submit">Create Post</button>
</form>
)
}
### ▶ 示例:startTransition + 客户端错误处理(难度⭐⭐⭐)
'use client' import { useTransition, useState } from 'react' import { revalidateTag } from 'next/cache'
async function subscribeNewsletter(formData: FormData) { 'use server' const email = formData.get('email') as string if (!email || !email.includes('@')) throw new Error('Invalid email') await fetch('https://jsonplaceholder.typicode.com/posts', { method: 'POST', body: JSON.stringify({ title: email, body: 'Newsletter subscription' }) }) revalidateTag('newsletter') }
export default function NewsletterForm() {
const [isPending, startTransition] = useTransition()
const [error, setError] = useState<string | null>(null)
return (
<form onSubmit={e => {
e.preventDefault()
const form = e.currentTarget
const data = new FormData(form)
setError(null)
startTransition(async () => {
try {
await subscribeNewsletter(data)
form.reset()
} catch (err: any) {
setError(err.message)
}
})
}}>
<input type="email" name="email" required placeholder="your@email.com" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? 'Subscribing...' : 'Subscribe'}
</button>
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
</form>
)
}
---
## 7. Server Actions vs API Routes 选型
| 维度 | Server Actions | API Routes |
|:-----|:--------------|:-----------|
| 调用方式 | RSC Payload 协议 | HTTP (REST) |
| 适用场景 | 第一方表单、用户操作 | 第三方 API、Webhook、移动端 |
| CSRF 保护 | ✅ 内置 | ❌ 需手动 |
| 渐进增强 | ✅ 支持 | ❌ 需要 JS |
| 类型安全 | ✅ 完整 TS 类型 | ⚠️ 手动处理 |
| 文件上传 | ✅ formData 直接处理 | ✅ req 流处理 |
| 认证方式 | `auth()` 函数 | Middleware / JWT |
| Rate Limiting | ⚠️ 需手动实现 | ✅ Middleware 统一处理 |
| 缓存失效 | ✅ 内置 revalidate | ✅ 可调用 revalidateTag |
| 调试难度 | 低(函数调用) | 中(HTTP 调试)|
### (8) ▶ 选型建议
graph TB A[需要什么形式的接口?] --> B{调用方是谁?} B -->|浏览器直接调用| C{有表单吗?} B -->|第三方 / Webhook / 移动端| D[API Route] C -->|是表单| E[Server Action ✅] C -->|否,JSON 交互| F{需要文件上传?} F -->|是| E F -->|否| D
---
## 8. 完整示例:带乐观更新 + 文件上传的任务详情
// app/tasks/[id]/page.tsx — Server Actions 进阶综合 import { revalidateTag } from 'next/cache' import { writeFile } from 'node:fs/promises' import { join } from 'node:path' import { z } from 'zod'
// ======== Schemas ======== const commentSchema = z.object({ content: z.string().min(1).max(1000), author: z.string().min(2).max(100), })
// ======== 任务详情页 ========
export default async function TaskDetailPage({ params }: { params: { id: string } }) {
const task = await fetch(https://jsonplaceholder.typicode.com/todos/${params.id}).then(r => r.json())
const comments = await fetch(https://jsonplaceholder.typicode.com/posts/${params.id}/comments, {
next: { tags: [comments-${params.id}] }
}).then(r => r.json())
return (
<div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
<h1>{task.title}</h1>
<p>Status: {task.completed ? '✅ Completed' : '⏳ Pending'}</p>
{/* 快速切换状态 */}
`<form action={async (formData: FormData) =>` {
'use server'
const id = Number(formData.get('id'))
const completed = formData.get('completed') === 'true'
await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`, {
method: 'PATCH',
body: JSON.stringify({ completed: !completed })
})
revalidateTag(`task-${id}`)
}}>
`<input type="hidden" name="id" value={params.id} />`
`<input type="hidden" name="completed" value={String(task.completed)} />`
`<button type="submit">`{task.completed ? 'Mark Pending' : 'Mark Complete'}`</button>`
`</form>`
{/* 添加评论 */}
`<h2>`Comments`</h2>`
`<form action={async (formData: FormData) =>` {
'use server'
const validated = commentSchema.safeParse({
content: formData.get('content'),
author: formData.get('author'),
})
if (!validated.success) return { errors: validated.error.flatten().fieldErrors }
await fetch('https://jsonplaceholder.typicode.com/comments', {
method: 'POST',
body: JSON.stringify({
postId: Number(params.id),
name: validated.data.author,
body: validated.data.content,
email: 'user@example.com',
})
})
revalidateTag(`comments-${params.id}`)
}}>
`<div>`<input name="author" required placeholder="Your name" />`</div>
`<div>`<textarea name="content" required placeholder="Comment" />`</div>
`<button type="submit">`Add Comment`</button>`
`</form>`
`<ul>`{comments.map((c: any) => (
`<li key={c.id}>`<strong>`{c.name}:`</strong>` {c.body}`</li>`
))}`</ul>`
{/* 文件上传 */}
`<h2>`Attachments`</h2>`
`<form action={async (formData: FormData) =>` {
'use server'
const file = formData.get('file') as File
if (!file || file.size === 0) return { error: 'No file' }
if (file.size > 5 * 1024 * 1024) return { error: 'File too large' }
const bytes = await file.arrayBuffer()
await writeFile(join('public/uploads', `${Date.now()}-${file.name}`), Buffer.from(bytes))
revalidateTag(`attachments-${params.id}`)
}}>
`<input type="file" name="file" />`
`<button type="submit">`Upload`</button>`
`</form>`
`</div>`
) }
❓ 常见问题
file.type(MIME 类型)和 file.size(字节数)做校验。推荐的方式是将文件保存到文件系统或云存储(S3/R2),在数据库中保存路径引用。next/navigation 导入,在 Server Action 中调用时会抛出特殊的重定向异常。它必须在 try-catch 块外部调用——如果放在 try 块内会被 catch 捕获。正确的模式是:校验 → 写入 → revalidate → redirect(不在 try 块内)。cookies() 函数设置响应头:const cookieStore = cookies(); cookieStore.set('theme', 'dark')。但注意 Server Action 的 cookie 操作是批量生效的,不能在同一个 action 中先设置后读取。📖 小节
- 错误处理三模式:useActionState(推荐)、startTransition、try-catch
useOptimistic在 Server Action 完成前即时更新 UI,失败后自动回滚- 文件上传通过
formData.get('file') as File获取文件流,校验类型和大小 - 事务模式:Zod 校验 → 数据库写入 → revalidateTag → redirect
- Server Actions 适合第一方表单操作,API Routes 适合第三方集成
- 大文件上传建议拆分成异步任务处理
redirect()不能在 try 块内调用,需放在事务收尾位置
📝 作业
-
基础题(⭐):创建一个
app/quick-todo/page.tsx,使用内联 Server Action 实现添加/删除 todo,在 Server Action 中用 try-catch 捕获错误并返回{ error: string }给客户端显示。 -
进阶题(⭐⭐):构建一个
app/gallery/page.tsx多图上传页面,支持一次选择最多 5 张图片,在 Server Action 中校验类型(仅图片)和大小(每张 ≤ 2MB),上传后显示缩略图列表。在 Server Action 中实现writeFile存储 +revalidatePath刷新。 -
挑战题(⭐⭐⭐):实现一个"任务看板"页面,包含三个状态列(Todo / In Progress / Done)。使用
useOptimistic实现拖拽切换状态时的即时 UI 更新(不需要真实拖拽,用按钮切换即可)。Server Action 写入后自动revalidateTag刷新所有列。添加文件上传到每个任务卡片。