Next.js: Server Actions 进阶

最后更新:2026-08-26

2. 一个前端工程师的真实故事

(1) 痛点:上传文件要 8 秒,用户以为页面卡死了

Diana 正在开发 TaskFlow 的"附件上传"功能。用户选择一个 5 MB 的图片后:

更糟的是,这 6 秒里没有任何 UI 反馈——用户以为页面卡死,会重复点击提交。

(2) Server Actions 的解法

useOptimistic 立即在 UI 中显示"虚拟"附件 + Server Action 统一处理上传 + try-catch 错误捕获。

TSX
// 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 模式(推荐)

TSX
// 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 模式

TSX
// 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 内)

TS
// 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')
  }
}

▶ 示例:错误处理三模式对比(难度⭐⭐)

TSX
// 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,然后在实际结果返回时自动回滚或确认。

100%
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 展示 实际数据 用户感知
乐观更新 ✅ 已完成(打勾) ⏳ 处理中 即时
服务器确认 ✅ 已完成 ✅ 已完成 无变化
服务器拒绝 ❌ 未完成(回滚) ❌ 未完成 回滚动画

▶ 示例:乐观更新任务状态(难度⭐⭐⭐)

TSX
// 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) 服务端文件处理

TS
// 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) 客户端上传表单

TSX
// 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) 多文件上传

TSX
// 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 }
}

▶ 示例:完整文件上传 + 预览(难度⭐⭐⭐)

TSX
// 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 通常包含三个步骤:写入数据库 → 清除缓存 → 重定向用户

100%
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() 提交后跳转到详情页或列表页

▶ 示例:完整事务模式(难度⭐⭐⭐)

TSX
// 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> ) }

TEXT 📖 仅展示

---

## 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

TEXT 📖 仅展示

---

## 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>`

) }


❓ 常见问题

Q useOptimistic 和 useActionState 可以一起用吗?
A 可以。useOptimistic 用于提前更新 UI,useActionState 用于获取服务器实际返回的最终状态。常见模式:useOptimistic 管理即时 UI 变化,useActionState 接收服务器响应后确认或回滚。
Q Server Action 中的文件上传支持哪些文件类型?
A 所有浏览器支持的 File 类型都可以。服务端可以读取 file.type(MIME 类型)和 file.size(字节数)做校验。推荐的方式是将文件保存到文件系统或云存储(S3/R2),在数据库中保存路径引用。
Q redirect() 在 Server Action 中如何使用?
A redirect() 需要从 next/navigation 导入,在 Server Action 中调用时会抛出特殊的重定向异常。它必须在 try-catch 块外部调用——如果放在 try 块内会被 catch 捕获。正确的模式是:校验 → 写入 → revalidate → redirect(不在 try 块内)。
Q Server Action 的超时时间是多久?
A Vercel 免费计划:10 秒(Serverless Function)、60 秒(Pro)。自托管:由你的 Node.js 服务器配置决定(默认无限制)。长时间操作(视频处理)建议拆分为异步任务 + Webhook 回调,而不是在 Server Action 中同步等待。
Q Server Action 可以调用多次 set-cookie 吗?
A 可以。使用 cookies() 函数设置响应头:const cookieStore = cookies(); cookieStore.set('theme', 'dark')。但注意 Server Action 的 cookie 操作是批量生效的,不能在同一个 action 中先设置后读取。
Q 什么场景应该用 Server Action 而不是 API Route?
A 三种场景优先用 Server Action:① 第一方用户操作(表单提交、按钮点击);② 需要渐进增强(JS 禁用仍可用);③ 与 RSC 缓存系统紧密集成(revalidateTag/revalidatePath)。API Route 的优势在于:① 第三方集成(移动端、Webhook);② 不需要 HTML 页面上下文;③ 需要标准 RESTful 接口。

📖 小节


📝 作业

  1. 基础题(⭐):创建一个 app/quick-todo/page.tsx,使用内联 Server Action 实现添加/删除 todo,在 Server Action 中用 try-catch 捕获错误并返回 { error: string } 给客户端显示。

  2. 进阶题(⭐⭐):构建一个 app/gallery/page.tsx 多图上传页面,支持一次选择最多 5 张图片,在 Server Action 中校验类型(仅图片)和大小(每张 ≤ 2MB),上传后显示缩略图列表。在 Server Action 中实现 writeFile 存储 + revalidatePath 刷新。

  3. 挑战题(⭐⭐⭐):实现一个"任务看板"页面,包含三个状态列(Todo / In Progress / Done)。使用 useOptimistic 实现拖拽切换状态时的即时 UI 更新(不需要真实拖拽,用按钮切换即可)。Server Action 写入后自动 revalidateTag 刷新所有列。添加文件上传到每个任务卡片。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏