Next.js Server Actions 实战全解:表单提交、数据校验、乐观更新与缓存失效(基于 claude-skills nextjs-developer 技能)

Next.js Server Actions 实战全解:表单提交、数据校验、乐观更新与缓存失效(基于 claude-skills nextjs-developer 技能)

【免费下载链接】claude-skills 67 Specialized Skills for Full-Stack Developers. Transform Claude Code into your expert pair programmer. 【免费下载链接】claude-skills 项目地址: https://gitcode.com/GitHub_Trending/claud/claude-skills

本指南以 claude-skills 仓库中 nextjs-developer 技能 的 server-actions.md 参考文档为骨架,系统讲解 Next.js 14+ App Router 中 Server Actions 的完整实战用法:从最基础的 'use server' 定义、<form> 绑定,到 Zod 校验、useFormState/useFormStatus 客户端集成、乐观更新、鉴权、缓存失效(revalidatePath/revalidateTag)、文件上传、Cookie 与限流。读完本文,你将能够把任意"表单提交 + 数据库变更 + 页面刷新"场景,以最小样板代码、最大类型安全的方式落地到生产项目。

本技能在 claude-skills 中的定位

claude-skills 是面向全栈开发者的 67 个 Claude Code 专业技能的集合(见 README.md)。其中 nextjs-developer 是 frontend 领域下专注于 Next.js 14+ App Router、Server Components、Server Actions 的技能。在其 SKILL.md 的 Reference Guide 路由表中,明确列出了各参考文档的加载时机:

TopicReferenceLoad When
App Routerreferences/app-router.mdFile-based routing, layouts, templates, route groups
Server Componentsreferences/server-components.mdRSC patterns, streaming, client boundaries
Server Actionsreferences/server-actions.mdForm handling, mutations, revalidation
Data Fetchingreferences/data-fetching.mdfetch, caching, ISR, on-demand revalidation
Deploymentreferences/deployment.mdVercel, self-hosting, Docker, optimization

也就是说,本文所述的 server-actions.md 是技能在处理"表单处理、数据变更(mutation)、按需缓存失效"三类需求时自动加载的深度参考材料。技能本身还设定了若干强约束(MUST DO):始终使用 App Router、组件默认保持 Server Component、仅在交互边界加 'use client'、所有变更操作后显式失效缓存等,这与本文的 Best Practices 一脉相承。

从仓库校验脚本 scripts/validate-skills.py 可以看到,ReferencePathChecker 会校验技能目录下所有 Markdown 中引用的相对路径必须能从"所在文件目录或技能根目录"解析(对应 references/server-actions.md 这类文件自身的引用完整性);同时技能要求必须存在 references/ 目录且至少包含一个参考文件。这正是我们接下来要深入分析的参考文件的来源。

前置概念:'use server' 指令

Server Actions 的本质是:在 React Server Components 生态中,定义一个可以被客户端组件或表单调用的、只运行在服务端的异步函数。它有三种放置方式:

  1. 文件级:在文件顶部写 'use server',该文件导出的所有 async 函数都会成为 Server Action(适用于集中管理变更逻辑的 actions.ts);
  2. 内联级:在单个异步函数体内部写 'use server',只把该函数标记为 Server Action(详见"内联 Server Action"一节);
  3. 调用侧:客户端通过 <form action={fn}>、useFormState(fn, initialState) 或直接 await fn(data) 触发,框架自动把参数序列化后发往服务端。

技能在 SKILL.md 中强调的约束同样适用于这里:不要为了拿数据而把组件转成 Client Component——先服务端取数,再在最底层的交互叶子上使用 'use client' 与 Server Action 对接。

基础 Server Action:文件级定义与数据写入

最典型的基础形态,是在 app/actions.ts 中集中导出变更函数。参考文档的原始示例完整如下:

// app/actions.ts
'use server'

import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string
  const content = formData.get('content') as string

  await db.post.create({
    data: { title, content }
  })

  revalidatePath('/posts')
}

要点拆解:

  • 'use server' 位于文件顶部:整个文件被标记为"仅服务端",任何客户端导入都会得到一个可调用的引用,但函数体永不进入客户端 bundle;
  • FormData 作为参数:当函数被 <form action={fn}> 引用时,浏览器会把表单字段序列化为 FormData,因此用 formData.get('title') 取值。字段名与 <input name="title"> 一一对应;
  • db 来自 @/lib/db:这是项目的数据库访问单例。仓库同技能的 data-fetching.md 给出了标准实现——用全局变量缓存 PrismaClient,避免开发环境热更新时重复实例化:
// lib/db.ts
import { PrismaClient } from '@prisma/client'

const globalForPrisma = global as unknown as { prisma: PrismaClient }

export const db = globalForPrisma.prisma || new PrismaClient()

if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = db
  • revalidatePath('/posts'):数据变更后立即按路径失效缓存。这一步是本技能"变更后必须失效缓存"约束的直接体现(更多策略见下文"缓存失效策略")。

表单绑定:把 Server Action 传给 <form action>

在 Server Component 的页面里,把 Action 直接作为表单的 action 属性传入,即可获得"无 JavaScript 也能提交"的渐进增强体验(No-JS fallback 会自动生成隐藏的 $ACTION_ID_* 字段):

// app/posts/new/page.tsx
import { createPost } from '@/app/actions'

export default function NewPost() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">Create Post</button>
    </form>
  )
}

注意这里的页面本身是 Server Component(没有 'use client'),表单的 name 属性是 FormData 取值的唯一键,务必与 Action 中 formData.get(...) 的名称一致。required 只是浏览器端的首道防线,真正的校验必须在服务端做(见下一节)。

类型安全校验:Zod schema 与服务端双重校验

参考文档明确要求"在任何 mutation 之前使用 Zod/TypeBox 做校验"。在 Server Action 中读取的 FormData 字段类型都是 string | null,直接写入数据库是不可靠的。推荐模式是定义 schema,用 safeParse 校验并收集字段级错误:

// app/actions.ts
'use server'

import { z } from 'zod'
import { revalidatePath } from 'next/cache'

const CreatePostSchema = z.object({
  title: z.string().min(3).max(100),
  content: z.string().min(10),
})

export async function createPost(formData: FormData) {
  const validatedFields = CreatePostSchema.safeParse({
    title: formData.get('title'),
    content: formData.get('content'),
  })

  if (!validatedFields.success) {
    return {
      errors: validatedFields.error.flatten().fieldErrors,
    }
  }

  const { title, content } = validatedFields.data

  await db.post.create({
    data: { title, content }
  })

  revalidatePath('/posts')
  return { success: true }
}

几个值得注意的设计:

  • 返回值而非抛错:校验失败时返回 { errors: ... },zod 的 flatten().fieldErrors 会生成 { title?: string[], content?: string[] } 结构的字段级错误,可直接在客户端逐字段展示(配合下一节的 useFormState);
  • 类型收窄:通过 validatedFields.data 拿到的是经过 schema 推导的精确类型,后续 db.post.create 的 data 参数天然类型安全;
  • 成功时返回标记:{ success: true } 供客户端判断是否跳转。

客户端组件集成:useFormState + useFormStatus

纯服务端表单适合无交互场景;需要"提交中禁用按钮、字段级错误内联展示"时,就要在客户端组件中配合 React 的 useFormState 与 useFormStatus。参考文档示例完整如下:

// components/create-post-form.tsx
'use client'

import { createPost } from '@/app/actions'
import { useFormState, useFormStatus } from 'react-dom'

const initialState = {
  errors: {},
}

function SubmitButton() {
  const { pending } = useFormStatus()

  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Creating...' : 'Create Post'}
    </button>
  )
}

export function CreatePostForm() {
  const [state, formAction] = useFormState(createPost, initialState)

  return (
    <form action={formAction}>
      <div>
        <input name="title" />
        {state.errors?.title && <p>{state.errors.title[0]}</p>}
      </div>

      <div>
        <textarea name="content" />
        {state.errors?.content && <p>{state.errors.content[0]}</p>}
      </div>

      <SubmitButton />
    </form>
  )
}

关键机制:

  • useFormState(createPost, initialState):接收 Server Action 与初始状态,返回 [state, formAction]。Action 返回的对象会成为新的 state,从而把服务端校验错误"回流"到客户端渲染;
  • useFormStatus 必须放在 'use client' 子树内:它读取最近父级 <form> 的提交状态。示例把 SubmitButton 抽成独立组件,正是因为 useFormStatus 需要挂载在表单内部;pending 为真时禁用按钮并切换文案,避免重复提交;
  • 状态结构与 schema 对齐:initialState 的 { errors: {} } 与上面 Zod 校验失败返回的 { errors: fieldErrors } 结构保持一致,客户端用 state.errors?.title[0] 取第一个错误信息。

数据变更后的重定向:redirect()

创建成功后把用户带到详情页是常见需求。redirect() 必须由 next/navigation 导入,且只能抛出一个特殊的内部异常来终止渲染——因此它必须在 try/catch 之外、或在捕获后重抛,否则会被吞掉。参考文档示例:

// app/actions.ts
'use server'

import { redirect } from 'next/navigation'
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
  const post = await db.post.create({
    data: {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
    }
  })

  revalidatePath('/posts')
  redirect(`/posts/${post.id}`)
}

顺序很重要:先 revalidatePath 使列表缓存失效,再 redirect 到新资源。这样用户重定向后访问 /posts 或详情页时,拿到的都是最新数据。

乐观更新:useOptimistic

对于"新增 Todo"这类高频、低风险操作,等待服务端往返后再更新 UI 会显得迟钝。React 的 experimental_useOptimistic(现为 useOptimistic)允许先以"假设成功"的状态更新 UI,后台再发送请求。参考文档示例:

// components/todo-list.tsx
'use client'

import { experimental_useOptimistic as useOptimistic } from 'react'
import { toggleTodo } from '@/app/actions'

export function TodoList({ todos }: { todos: Todo[] }) {
  const [optimisticTodos, addOptimisticTodo] = useOptimistic(
    todos,
    (state, newTodo: Todo) => [...state, newTodo]
  )

  async function handleSubmit(formData: FormData) {
    const title = formData.get('title') as string
    const newTodo = { id: crypto.randomUUID(), title, completed: false }

    // Optimistically update UI
    addOptimisticTodo(newTodo)

    // Send to server
    await createTodo(formData)
  }

  return (
    <div>
      <ul>
        {optimisticTodos.map(todo => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>

      <form action={handleSubmit}>
        <input name="title" />
        <button type="submit">Add</button>
      </form>
    </div>
  )
}

要点:

  • useOptimistic(初始值, 更新函数) 返回 [当前显示值, 添加乐观值的方法];更新函数接收 (state, newTodo),返回合并后的新列表;
  • handleSubmit 先 addOptimisticTodo 立即渲染,再 await 服务端 Action;服务端返回后框架会根据真实数据自动对账,乐观状态被真实状态替换;
  • 本地生成临时 id(crypto.randomUUID())并传给 key,保证乐观渲染阶段列表也能正确协调;
  • 变更成功后仍需在服务端调用 revalidatePath(createTodo 内部),否则刷新页面数据会回退。

权限校验:在 mutation 前验证会话

Server Action 是真正的服务端函数,任何授权判断都必须放在这里,而不能依赖客户端 UI 的隐藏。参考文档的鉴权模式是"先取会话,无会话直接重定向到登录页":

// app/actions.ts
'use server'

import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'

export async function createPost(formData: FormData) {
  const session = await auth()

  if (!session) {
    redirect('/login')
  }

  await db.post.create({
    data: {
      title: formData.get('title') as string,
      content: formData.get('content') as string,
      authorId: session.user.id,
    }
  })

  revalidatePath('/posts')
}

除"是否登录"外,通常还需要在写入前校验资源归属(如 authorId === session.user.id 才能更新/删除自己的文章)。这与 SKILL.md 中"Verify session before mutations"的最佳实践一致。

内联 Server Action:在 Server Component 内部就地定义

当 Action 只服务于当前页面时,不必单独建文件,可以直接在 Server Component 里定义函数并在函数体首行写 'use server'。参考文档的删除示例:

// app/posts/page.tsx
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export default async function Posts() {
  const posts = await db.post.findMany()

  async function deletePost(formData: FormData) {
    'use server'

    const id = formData.get('id') as string
    await db.post.delete({ where: { id } })
    revalidatePath('/posts')
  }

  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>
          {post.title}
          <form action={deletePost}>
            <input type="hidden" name="id" value={post.id} />
            <button type="submit">Delete</button>
          </form>
        </li>
      ))}
    </ul>
  )
}

这里有一个值得注意的细节:在 Server Component 中,页面数据读取(db.post.findMany())与删除 Action(deletePost)同处一页。删除后调用 revalidatePath('/posts'),服务端会重新执行页面数据读取,从而刷新列表——这是 Server Actions + RSC 无需客户端状态管理即可保持 UI 同步的关键所在。同时注意通过 <input type="hidden" name="id"> 传递记录 ID 的技巧。

编程式调用:客户端直接 await

并非所有变更都来自表单,也可能是按钮点击。客户端组件可以直接 await Server Action 并接收其返回值,参考文档同时给出了客户端与服务端两侧代码:

// components/delete-button.tsx
'use client'

import { deletePost } from '@/app/actions'

export function DeleteButton({ postId }: { postId: string }) {
  async function handleDelete() {
    if (confirm('Are you sure?')) {
      await deletePost(postId)
    }
  }

  return (
    <button onClick={handleDelete}>
      Delete
    </button>
  )
}

// app/actions.ts
'use server'

export async function deletePost(postId: string) {
  await db.post.delete({ where: { id: postId } })
  revalidatePath('/posts')
}

对比表单绑定,编程式调用直接把普通参数(postId: string)传给 Action,无需 FormData。这要求参数必须是可序列化的(string、number、boolean、plain object 等),函数返回的值同样会序列化回客户端。参考文档的"Quick Reference"中将其归纳为 Call directly: await serverAction(data)。

缓存失效策略:revalidatePath 与 revalidateTag

这是 Server Actions 变更流程的收尾关键。参考文档完整展示了四种失效粒度:

// app/actions.ts
'use server'

import { revalidatePath, revalidateTag } from 'next/cache'

export async function updatePost(id: string, data: UpdatePostData) {
  await db.post.update({ where: { id }, data })

  // Revalidate specific path
  revalidatePath('/posts')
  revalidatePath(`/posts/${id}`)

  // Revalidate all paths in a layout
  revalidatePath('/posts', 'layout')

  // Revalidate by cache tag
  revalidateTag('posts')
}

四种用法的适用场景:

调用失效范围典型场景
revalidatePath('/posts')指定路径页面列表页被新增/删除影响
revalidatePath('/posts/' + id)动态路由下单个详情页单篇文章被编辑
revalidatePath('/posts', 'layout')该 segment 下所有路径布局级共享数据(如导航计数)
revalidateTag('posts')所有带 posts tag 的 fetch 缓存跨页面、跨路由的同类数据

与标签失效配套的是取数时的打标。同技能 data-fetching.md 给出了一致做法——取数时通过 fetch(url, { next: { tags: ['posts'] } }) 打标,变更时 revalidateTag('posts') 即可一次性失效所有带该标签的缓存;该参考文档还给出了在 Route Handler(app/api/revalidate/route.ts)中按 secret 鉴权的按需失效端点写法。对于使用 db(Prisma 等数据库直查)的场景,revalidatePath 是更直接的手段,因为数据库查询不走 fetch 缓存层。技能同时允许在路由段配置 export const revalidate = 60 做基于时间的 ISR 兜底。

文件上传:从 FormData 读取 File 并落盘

文件上传是 Server Action 的经典用例:<input type="file"> 的二进制内容会作为 File 出现在 FormData 中。参考文档示例:

// app/actions.ts
'use server'

import { writeFile } from 'fs/promises'
import { join } from 'path'

export async function uploadAvatar(formData: FormData) {
  const file = formData.get('avatar') as File

  if (!file) {
    return { error: 'No file uploaded' }
  }

  const bytes = await file.arrayBuffer()
  const buffer = Buffer.from(bytes)

  const path = join(process.cwd(), 'public', 'uploads', file.name)
  await writeFile(path, buffer)

  return { success: true, path: `/uploads/${file.name}` }
}

// components/upload-form.tsx
'use client'

import { uploadAvatar } from '@/app/actions'

export function UploadForm() {
  async function handleSubmit(formData: FormData) {
    const result = await uploadAvatar(formData)
    if (result.success) {
      console.log('Uploaded to:', result.path)
    }
  }

  return (
    <form action={handleSubmit}>
      <input type="file" name="avatar" accept="image/*" />
      <button type="submit">Upload</button>
    </form>
  )
}

实操注意点:

  • 读文件用 file.arrayBuffer(),得到二进制后再转 Buffer 写入磁盘(示例用 Node.js 的 fs/promises.writeFile);
  • 落盘位置:示例写入 public/uploads/,因此可直接通过 /uploads/${file.name} 公开访问;
  • 安全风险必须自行处理:示例未包含任何限制——生产环境应校验 MIME 类型与大小(可在 Zod schema 之外用 file.type/file.size 单独判断)、对文件名做随机化处理以防路径穿越,并考虑将文件存放到对象存储(S3 等)而非应用服务器磁盘。

错误处理:返回错误对象而非抛异常

Server Action 内部抛出的异常默认会进入最近的 error.tsx 错误边界。但对于表单这种需要内联反馈的场景,更合适的是返回结构化错误对象,由客户端决定如何展示。参考文档的完整示例:

// app/actions.ts
'use server'

export async function createPost(formData: FormData) {
  try {
    await db.post.create({
      data: {
        title: formData.get('title') as string,
        content: formData.get('content') as string,
      }
    })

    revalidatePath('/posts')
    return { success: true }
  } catch (error) {
    console.error('Failed to create post:', error)
    return { error: 'Failed to create post' }
  }
}

// components/form.tsx
'use client'

export function CreatePostForm() {
  const [error, setError] = useState<string | null>(null)

  async function handleSubmit(formData: FormData) {
    const result = await createPost(formData)

    if (result.error) {
      setError(result.error)
    } else {
      // Success
      router.push('/posts')
    }
  }

  return (
    <form action={handleSubmit}>
      {error && <div className="error">{error}</div>}
      {/* form fields */}
    </form>
  )
}

模式总结:Action 返回可区分的联合结果({ success: true } / { error: string }),客户端根据返回值分支处理——有错误则内联展示并保留表单内容,成功则用 router.push 跳转。注意 redirect() 不能在 try 块内调用(会被 catch 捕获而失效),这也是该示例在成功分支改用 router.push 的原因之一。对于未捕获的意外异常,SKILL.md 与 app-router.md 均要求为每个异步路由段配置 error.tsx(客户端错误边界)作为兜底。

Cookie 操作:读取与写入

Server Actions 运行于服务端,可以直接读写 cookie。参考文档的主题切换示例:

// app/actions.ts
'use server'

import { cookies } from 'next/headers'

export async function setTheme(theme: 'light' | 'dark') {
  cookies().set('theme', theme, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    maxAge: 60 * 60 * 24 * 365, // 1 year
    path: '/',
  })
}

export async function getTheme() {
  return cookies().get('theme')?.value ?? 'light'
}

参数说明:

  • httpOnly: true:阻止客户端 JavaScript 读取,降低 XSS 窃取风险;
  • secure:仅在生产环境(NODE_ENV === 'production')通过 HTTPS 传输时启用;
  • maxAge: 60 * 60 * 24 * 365:一年有效(单位秒);
  • path: '/':整站生效;
  • cookies().get(...) 读取,未命中时用 ?? 'light' 提供默认值。

由于主题类首屏 UI 依赖 cookie,实践中通常把 getTheme() 放在 Server Component 或 Layout 中读取并向下传递,避免客户端闪烁。

限流保护:对公开 mutation 做滥用防护

任何接受用户输入并写库的 Server Action 都可能被滥用,尤其涉及发送邮件、短信、AI 生成等高成本操作。参考文档给出基于 Redis 滑动窗口限流的示意:

// app/actions.ts
'use server'

import { ratelimit } from '@/lib/redis'

export async function createPost(formData: FormData) {
  const session = await auth()
  const { success } = await ratelimit.limit(session.user.id)

  if (!success) {
    return { error: 'Rate limit exceeded' }
  }

  // Create post...
}

要点:以登录用户 ID 作为限流键(未登录用户可回退到 IP),ratelimit.limit(key) 返回的 success 为 false 时直接返回错误、不执行后续写库逻辑。@/lib/redis 是对 Redis 客户端的封装,具体实现可选用 @upstash/ratelimit 或自建 Lua 脚本,仓库参考文档未限定实现,仅强调"在 mutation 前执行"这一时序。

快速参考表

参考文档结尾的速查表完整如下,覆盖了本文全部模式:

CapabilityUsage
DefineAdd 'use server' at top of file or function
FormPass action to <form action={serverAction}>
ProgrammaticCall directly: await serverAction(data)
ValidationUse Zod/TypeBox before mutations
RevalidaterevalidatePath() or revalidateTag()
Redirectredirect() after mutation
ErrorsReturn error objects, handle in client
FilesAccess via formData.get() as File

最佳实践

参考文档总结的七条最佳实践,可作为所有 Server Action 代码的验收清单:

  1. Always validate — Use Zod/TypeBox for type-safe validation(在任何写操作前做类型安全校验)
  2. Revalidate — Call revalidatePath() after mutations(变更后必须失效缓存,否则 UI 与数据不一致)
  3. Handle errors — Return error objects instead of throwing(返回结构化错误对象而非抛异常,让客户端可控地展示)
  4. Auth checks — Verify session before mutations(在 mutation 前验证会话与资源归属)
  5. Rate limiting — Protect against abuse(对公开操作做限流,防滥用)
  6. Type safety — Define input/output types(定义输入输出类型,保证端到端类型安全)
  7. Optimistic updates — Use useOptimistic for better UX(高频低风险操作优先乐观更新,提升交互感知)

与之呼应,SKILL.md 的 Constraints 也强调:组件默认保持 Server Component、只在需要交互的叶子节点加 'use client'、不要为了取数据而把组件转成客户端组件——这些约束与上述最佳实践共同构成 Server Actions 的推荐架构边界。

延伸阅读

【免费下载链接】claude-skills 67 Specialized Skills for Full-Stack Developers. Transform Claude Code into your expert pair programmer. 【免费下载链接】claude-skills 项目地址: https://gitcode.com/GitHub_Trending/claud/claude-skills

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值