🙋 传统方式需要创建 API 路由来处理表单?Server Actions 让你直接从客户端调用服务端函数。
什么是 Server Actions#
Server Actions 是在服务器上执行的异步函数,可以直接从 Client 或 Server Components 调用。
// 传统方式// 1. 创建 API 路由 /api/submit// 2. 客户端 fetch 调用// 3. 处理响应
// Server Actions 方式// 直接调用服务端函数,无需创建 API基础用法#
定义 Server Action#
'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 中使用#
// Next.js 15.ximport { 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 中使用#
'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> )}表单处理#
带验证的表单#
'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: '发布失败,请重试' } }}'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> )}文件上传#
'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}` }}'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#
'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> )}'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')}乐观列表操作#
'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> )}重定向与重新验证#
操作后重定向#
'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')}按标签重新验证#
'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')}错误处理#
返回错误状态#
'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#
'use server'
export async function dangerousAction() { // 抛出的错误会被最近的 error.tsx 捕获 throw new Error('操作失败')}'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> )}安全性#
输入验证#
'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 })}权限检查#
'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 }}防止重放攻击#
'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 操作#
'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')}'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 |
| Webhook | API Routes |
| 公开 API | API Routes |
🤔 Q: Server Actions 安全吗?
是的。Server Actions:
- 自动加密传输
- 支持 CSRF 保护
- 仅暴露必要的端点
🤔 Q: 如何调试 Server Actions?
'use server'
export async function myAction(formData: FormData) { console.log('收到数据:', Object.fromEntries(formData)) // 日志会出现在服务器终端}下一篇将介绍 Route Handlers,学习如何创建自定义 API 端点。
-EOF-