Skip to content

Server Actions

🙋 传统方式需要创建 API 路由来处理表单?Server Actions 让你直接从客户端调用服务端函数。

什么是 Server Actions#

Server Actions 是在服务器上执行的异步函数,可以直接从 Client 或 Server Components 调用。

// 传统方式
// 1. 创建 API 路由 /api/submit
// 2. 客户端 fetch 调用
// 3. 处理响应
// Server Actions 方式
// 直接调用服务端函数,无需创建 API

基础用法#

定义 Server Action#

app/actions.ts
'use server'
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 },
})
return { success: true }
}

在 Server Component 中使用#

app/posts/new/page.tsx
// Next.js 15.x
import { createPost } from '@/app/actions'
export default function NewPostPage() {
return (
<form action={createPost}>
<input name="title" placeholder="标题" required />
<textarea name="content" placeholder="内容" required />
<button type="submit">发布</button>
</form>
)
}

在 Client Component 中使用#

components/PostForm.tsx
'use client'
import { createPost } from '@/app/actions'
import { useActionState } from 'react'
export function PostForm() {
const [state, formAction, isPending] = useActionState(createPost, null)
return (
<form action={formAction}>
<input name="title" placeholder="标题" disabled={isPending} />
<textarea name="content" placeholder="内容" disabled={isPending} />
<button type="submit" disabled={isPending}>
{isPending ? '发布中...' : '发布'}
</button>
{state?.error && <p className="text-red-500">{state.error}</p>}
</form>
)
}

表单处理#

带验证的表单#

app/actions.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
const PostSchema = z.object({
title: z.string().min(1, '标题不能为空').max(100, '标题过长'),
content: z.string().min(10, '内容至少 10 个字符'),
})
export async function createPost(prevState: any, formData: FormData) {
// 验证数据
const validatedFields = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
})
if (!validatedFields.success) {
return {
errors: validatedFields.error.flatten().fieldErrors,
message: '验证失败',
}
}
const { title, content } = validatedFields.data
try {
await db.post.create({ data: { title, content } })
// 重新验证缓存
revalidatePath('/posts')
return { message: '发布成功' }
} catch (error) {
return { message: '发布失败,请重试' }
}
}
components/PostForm.tsx
'use client'
import { useActionState } from 'react'
import { createPost } from '@/app/actions'
const initialState = {
message: '',
errors: {},
}
export function PostForm() {
const [state, formAction, isPending] = useActionState(
createPost,
initialState
)
return (
<form action={formAction} className="space-y-4">
<div>
<label className="block font-medium">标题</label>
<input
name="title"
className="w-full border rounded px-3 py-2"
aria-describedby="title-error"
/>
{state.errors?.title && (
<p id="title-error" className="text-red-500 text-sm mt-1">
{state.errors.title[0]}
</p>
)}
</div>
<div>
<label className="block font-medium">内容</label>
<textarea
name="content"
rows={5}
className="w-full border rounded px-3 py-2"
aria-describedby="content-error"
/>
{state.errors?.content && (
<p id="content-error" className="text-red-500 text-sm mt-1">
{state.errors.content[0]}
</p>
)}
</div>
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
>
{isPending ? '提交中...' : '发布'}
</button>
{state.message && (
<p className={state.errors ? 'text-red-500' : 'text-green-500'}>
{state.message}
</p>
)}
</form>
)
}

文件上传#

app/actions.ts
'use server'
import { writeFile } from 'fs/promises'
import { join } from 'path'
export async function uploadFile(formData: FormData) {
const file = formData.get('file') as File
if (!file) {
return { error: '请选择文件' }
}
// 验证文件类型
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']
if (!allowedTypes.includes(file.type)) {
return { error: '只支持 JPG、PNG、WebP 格式' }
}
// 验证文件大小(5MB)
if (file.size > 5 * 1024 * 1024) {
return { error: '文件大小不能超过 5MB' }
}
const bytes = await file.arrayBuffer()
const buffer = Buffer.from(bytes)
// 生成唯一文件名
const uniqueName = `${Date.now()}-${file.name}`
const path = join(process.cwd(), 'public', 'uploads', uniqueName)
await writeFile(path, buffer)
return { url: `/uploads/${uniqueName}` }
}
components/FileUpload.tsx
'use client'
import { useState } from 'react'
import { uploadFile } from '@/app/actions'
export function FileUpload() {
const [uploading, setUploading] = useState(false)
const [result, setResult] = useState<{ url?: string; error?: string } | null>(
null
)
async function handleSubmit(formData: FormData) {
setUploading(true)
const res = await uploadFile(formData)
setResult(res)
setUploading(false)
}
return (
<form action={handleSubmit}>
<input type="file" name="file" accept="image/*" />
<button type="submit" disabled={uploading}>
{uploading ? '上传中...' : '上传'}
</button>
{result?.error && <p className="text-red-500">{result.error}</p>}
{result?.url && (
<img src={result.url} alt="Uploaded" className="mt-4 max-w-xs" />
)}
</form>
)
}

乐观更新#

useOptimistic#

components/LikeButton.tsx
'use client'
import { useOptimistic, useTransition } from 'react'
import { toggleLike } from '@/app/actions'
interface Props {
postId: string
initialLiked: boolean
initialCount: number
}
export function LikeButton({ postId, initialLiked, initialCount }: Props) {
const [isPending, startTransition] = useTransition()
const [optimisticState, addOptimistic] = useOptimistic(
{ liked: initialLiked, count: initialCount },
(state, newLiked: boolean) => ({
liked: newLiked,
count: state.count + (newLiked ? 1 : -1),
})
)
function handleClick() {
startTransition(async () => {
// 立即更新 UI
addOptimistic(!optimisticState.liked)
// 执行服务端操作
await toggleLike(postId)
})
}
return (
<button
onClick={handleClick}
disabled={isPending}
className="flex items-center gap-2"
>
<span>{optimisticState.liked ? '❤️' : '🤍'}</span>
<span>{optimisticState.count}</span>
</button>
)
}
app/actions.ts
'use server'
import { revalidatePath } from 'next/cache'
export async function toggleLike(postId: string) {
const userId = await getCurrentUserId()
const existing = await db.like.findUnique({
where: { postId_userId: { postId, userId } },
})
if (existing) {
await db.like.delete({
where: { postId_userId: { postId, userId } },
})
} else {
await db.like.create({
data: { postId, userId },
})
}
revalidatePath('/posts')
}

乐观列表操作#

components/TodoList.tsx
'use client'
import { useOptimistic } from 'react'
import { addTodo, deleteTodo } from '@/app/actions'
interface Todo {
id: string
text: string
pending?: boolean
}
export function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
initialTodos,
(state, action: { type: 'add' | 'delete'; todo?: Todo; id?: string }) => {
if (action.type === 'add' && action.todo) {
return [...state, { ...action.todo, pending: true }]
}
if (action.type === 'delete' && action.id) {
return state.filter((t) => t.id !== action.id)
}
return state
}
)
async function handleAdd(formData: FormData) {
const text = formData.get('text') as string
const tempId = `temp-${Date.now()}`
addOptimisticTodo({ type: 'add', todo: { id: tempId, text } })
await addTodo(formData)
}
async function handleDelete(id: string) {
addOptimisticTodo({ type: 'delete', id })
await deleteTodo(id)
}
return (
<div>
<form action={handleAdd} className="flex gap-2 mb-4">
<input
name="text"
placeholder="新任务"
className="border rounded px-2"
/>
<button type="submit" className="bg-blue-600 text-white px-4 rounded">
添加
</button>
</form>
<ul className="space-y-2">
{optimisticTodos.map((todo) => (
<li
key={todo.id}
className={`flex justify-between items-center p-2 border rounded ${
todo.pending ? 'opacity-50' : ''
}`}
>
<span>{todo.text}</span>
<button
onClick={() => handleDelete(todo.id)}
className="text-red-500"
>
删除
</button>
</li>
))}
</ul>
</div>
)
}

重定向与重新验证#

操作后重定向#

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}`)
}
export async function deletePost(id: string) {
await db.post.delete({ where: { id } })
revalidatePath('/posts')
redirect('/posts')
}

按标签重新验证#

app/actions.ts
'use server'
import { revalidateTag } from 'next/cache'
export async function updateProduct(id: string, formData: FormData) {
await db.product.update({
where: { id },
data: {
name: formData.get('name') as string,
price: Number(formData.get('price')),
},
})
// 重新验证特定产品的缓存
revalidateTag(`product-${id}`)
// 重新验证产品列表
revalidateTag('products')
}

错误处理#

返回错误状态#

app/actions.ts
'use server'
export async function createUser(formData: FormData) {
const email = formData.get('email') as string
// 检查邮箱是否已存在
const existing = await db.user.findUnique({ where: { email } })
if (existing) {
return { error: '该邮箱已被注册' }
}
try {
const user = await db.user.create({
data: {
email,
name: formData.get('name') as string,
},
})
return { success: true, user }
} catch (error) {
return { error: '创建用户失败,请重试' }
}
}

使用 error.tsx#

app/actions.ts
'use server'
export async function dangerousAction() {
// 抛出的错误会被最近的 error.tsx 捕获
throw new Error('操作失败')
}
app/error.tsx
'use client'
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
return (
<div className="p-4 bg-red-50 rounded">
<h2 className="text-red-600 font-bold">出错了</h2>
<p>{error.message}</p>
<button onClick={reset} className="mt-2 text-blue-600">
重试
</button>
</div>
)
}

安全性#

输入验证#

app/actions.ts
'use server'
import { z } from 'zod'
const CommentSchema = z.object({
content: z
.string()
.min(1, '评论不能为空')
.max(1000, '评论过长')
.transform((s) => s.trim()),
postId: z.string().uuid('无效的文章 ID'),
})
export async function addComment(formData: FormData) {
const result = CommentSchema.safeParse({
content: formData.get('content'),
postId: formData.get('postId'),
})
if (!result.success) {
return { error: result.error.flatten().fieldErrors }
}
// 使用验证后的数据
await db.comment.create({ data: result.data })
}

权限检查#

app/actions.ts
'use server'
import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'
export async function updateProfile(formData: FormData) {
const session = await auth()
// 未登录重定向
if (!session?.user) {
redirect('/login')
}
// 只能修改自己的资料
const userId = formData.get('userId') as string
if (userId !== session.user.id) {
return { error: '无权修改' }
}
await db.user.update({
where: { id: userId },
data: { name: formData.get('name') as string },
})
return { success: true }
}

防止重放攻击#

app/actions.ts
'use server'
import { headers } from 'next/headers'
export async function sensitiveAction(formData: FormData) {
const headersList = await headers()
const csrfToken = headersList.get('x-csrf-token')
// 验证 CSRF Token
if (!validateCsrfToken(csrfToken)) {
return { error: '无效的请求' }
}
// 执行操作
}

实战示例#

完整的 CRUD 操作#

app/actions/posts.ts
'use server'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
const PostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
published: z.boolean().default(false),
})
// 创建
export async function createPost(prevState: any, formData: FormData) {
const session = await auth()
if (!session?.user) {
return { error: '请先登录' }
}
const validated = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'true',
})
if (!validated.success) {
return { errors: validated.error.flatten().fieldErrors }
}
const post = await db.post.create({
data: {
...validated.data,
authorId: session.user.id,
},
})
revalidatePath('/posts')
redirect(`/posts/${post.id}`)
}
// 更新
export async function updatePost(
id: string,
prevState: any,
formData: FormData
) {
const session = await auth()
if (!session?.user) {
return { error: '请先登录' }
}
const post = await db.post.findUnique({ where: { id } })
if (!post || post.authorId !== session.user.id) {
return { error: '无权修改' }
}
const validated = PostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
published: formData.get('published') === 'true',
})
if (!validated.success) {
return { errors: validated.error.flatten().fieldErrors }
}
await db.post.update({
where: { id },
data: validated.data,
})
revalidatePath('/posts')
revalidatePath(`/posts/${id}`)
return { success: true }
}
// 删除
export async function deletePost(id: string) {
const session = await auth()
if (!session?.user) {
return { error: '请先登录' }
}
const post = await db.post.findUnique({ where: { id } })
if (!post || post.authorId !== session.user.id) {
return { error: '无权删除' }
}
await db.post.delete({ where: { id } })
revalidatePath('/posts')
redirect('/posts')
}
components/PostEditor.tsx
'use client'
import { useActionState } from 'react'
import { createPost, updatePost } from '@/app/actions/posts'
interface Post {
id: string
title: string
content: string
published: boolean
}
interface Props {
post?: Post
}
export function PostEditor({ post }: Props) {
const action = post ? updatePost.bind(null, post.id) : createPost
const [state, formAction, isPending] = useActionState(action, null)
return (
<form action={formAction} className="space-y-4 max-w-2xl">
<div>
<label className="block font-medium mb-1">标题</label>
<input
name="title"
defaultValue={post?.title}
className="w-full border rounded px-3 py-2"
/>
{state?.errors?.title && (
<p className="text-red-500 text-sm">{state.errors.title[0]}</p>
)}
</div>
<div>
<label className="block font-medium mb-1">内容</label>
<textarea
name="content"
defaultValue={post?.content}
rows={10}
className="w-full border rounded px-3 py-2"
/>
{state?.errors?.content && (
<p className="text-red-500 text-sm">{state.errors.content[0]}</p>
)}
</div>
<label className="flex items-center gap-2">
<input
type="checkbox"
name="published"
value="true"
defaultChecked={post?.published}
/>
<span>立即发布</span>
</label>
{state?.error && <p className="text-red-500">{state.error}</p>}
<button
type="submit"
disabled={isPending}
className="bg-blue-600 text-white px-6 py-2 rounded disabled:opacity-50"
>
{isPending ? '保存中...' : post ? '更新' : '创建'}
</button>
</form>
)
}

常见问题#

🤔 Q: Server Actions 和 API Routes 怎么选?

场景推荐方案
表单提交Server Actions
数据变更Server Actions
第三方调用API Routes
WebhookAPI Routes
公开 APIAPI Routes

🤔 Q: Server Actions 安全吗?

是的。Server Actions:

🤔 Q: 如何调试 Server Actions?

'use server'
export async function myAction(formData: FormData) {
console.log('收到数据:', Object.fromEntries(formData))
// 日志会出现在服务器终端
}

下一篇将介绍 Route Handlers,学习如何创建自定义 API 端点。

-EOF-