Next.js: Server Actions 入门

最后更新:2026-08-26

Server Actions 是 Next.js 的"超能力"——它让浏览器直接调用服务端函数,无需手动构建 API 端点。

1. 你将学到


2. 一个全栈开发者的真实故事

(1) 痛点:一个"简单"的表单提交要写 80 行

Bob 正在 TaskFlow 中添加"创建项目"功能。传统做法需要:

Bob 叹气:"我只是要提交一个表单而已。"

(2) Server Actions 的解法

用一个 'use server' 函数直接处理表单——零客户端 JS 开销,零 API Route。

TSX
// 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 的核心工作流程

100%
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) 单独文件模式(推荐)

TS
// 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')
}
TSX
// 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) 内联模式(简单场景)

TSX
// 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 模式(推荐带状态表单)

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

▶ 示例:三种定义方式对比(难度⭐)

TSX
// 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>
  )
}
TSX
// 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>
  )
}
TS
// 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 表单行为

TSX
// 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 禁用场景测试(难度⭐⭐)

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

测试方法

  1. 正常使用:输入文本 → 点击提交 → 列表更新
  2. JS 禁用:DevTools → Settings → Disable JavaScript → 刷新 → 提交表单 → 仍然生效

6. Zod 校验集成

Server Actions 应该始终对输入数据进行校验。Zod 是 TypeScript 生态最流行的校验库。

(1) 基本校验模式

TS
// 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(),
})
TS
// 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) 客户端显示校验错误

TSX
// 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 校验表单(难度⭐⭐)

TSX
// 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 刷新列表(难度⭐)

TSX
// 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() 方法传递额外的参数。

TSX
// 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. 完整示例:任务管理系统

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

❓ 常见问题

Q Server Action 和传统 API Route 有什么区别?
A Server Action 是服务端函数,浏览器直接调用(通过 RSC Payload 协议),不需要 HTTP 端点、自动处理 CSRF、支持渐进增强。API Route 是 HTTP 端点,适合第三方集成、Webhook、非浏览器客户端。两者互不替代——Server Action 处理第一方表单,API Route 处理第三方 API。
Q useActionState 只能在 Client Component 中使用吗?
A 是的。useActionState 是 React 19 的客户端 Hook,需要 'use client'。它在客户端管理表单状态(pending、error、success)。纯 Server Component 可以使用 <form action={async} > 内联模式。
Q Server Actions 如何防止 CSRF 攻击?
A Next.js 16 内置了 CSRF 防护。Server Actions 只能通过 RSC Payload 协议调用(不能通过普通 HTTP POST 模拟)。框架会自动校验请求来源和 cookie 一致性,不需要手动添加 CSRF Token。
Q 内联 Server Action 可以引用组件中的 Props 吗?
A 不可以。内联 'use server' 是在编译时标记的独立模块函数——它不能访问闭包中的变量。如果需要使用组件的 props 或 state,必须通过 formData 传递,或在单独的 actions.ts 文件中定义。
Q Server Action 的参数可以是任意类型吗?
A Server Action 的参数必须是可序列化的(与 RSC Props 规则一致)。支持 FormData、普通对象、数组、字符串、数字。不支持函数、Date、undefined、Symbol。如果需要复杂参数,通过 formData 或 JSON 序列化传递。
Q Server Action 的返回值如何被客户端获取?
A 返回值通过 RSC Payload 序列化回客户端。在 <form action={action}> 中,返回值被忽略。要获取返回值,需使用 useActionState(action, initialState)startTransition + 显式 callAction()。推荐使用 useActionState 管理返回值。

📖 小节


📝 作业

  1. 基础题(⭐):创建一个 app/guestbook/page.tsx 留言板,使用内联 Server Action 处理表单提交,将留言内容通过 console.log 输出(模拟数据库写入),提交后 revalidatePath 刷新页面。

  2. 进阶题(⭐⭐):在 app/todos-zod/page.tsx 中实现带 Zod 校验的任务管理系统。Schema 要求:title(1-100 字符)、priority(枚举 low/medium/high)、dueDate(可选,YYYY-MM-DD 格式)。在表单下方显示具体的字段级校验错误。

  3. 挑战题(⭐⭐⭐):构建一个完整的"项目创建"模块:app/projects/create/page.tsx 包含多字段表单(名称、描述、截止日期、团队成员列表),app/actions/projects.ts 中定义 CRUD Server Actions,使用 useActionState 在客户端管理提交状态(success/error/pending),支持图片 URL 输入并预览。

Web-Tutorial.com

Web-Tutorial 技术团队

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

100%

🙏 帮我们做得更好

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

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