服务器动作还是路由处理程序?Next.js 16的决策指南
了解在何种情况下 Next.js 16 中的 mutation 应该放在 Server Action 中,又在何种情况下需要 Route Handler,并通过针对表单、错误处理、乐观更新和 webhook 的修正示例进行说明。
Server Actions最早出现在Next.js 13.4中,作为表单提交的快捷方式,如今已成为App Router的核心功能。但每出现一项新特性,都会引发同样的问题:应该使用Action还是API路由?本文将介绍做出选择的思维模型、两种代码实现方式,以及那些会导致实际错误的常见误解及其解决方案。
两种不同的契约
API路由在App Router中被称为Route Handlers(位于app/api/.../route.ts),属于普通的HTTP端点。它们拥有任何客户端都可以调用的稳定URL,能够完全控制状态码和缓存头信息,且与组件之间没有关联。
Server Actions则是从React代码中调用的'use server'函数。Next.js会生成相应的端点,对参数和结果进行序列化处理,并将它们与表单、页面过渡以及乐观UI功能相结合,同时还会将类型信息传递给调用处。
与常见说法相反,Server Actions并非私有的:每个操作都是一个以操作ID为键的POST接口,任何拥有该ID的人都可以使用任意参数来调用它。
经验法则:外部调用者适合使用Route Handler;而由自身UI触发的变更操作通常适合用Server Action。
Server Actions的实际应用
基于表单的变更操作
该文件以某条指令开头,这条指令将所有导出的函数标记为服务器函数:
// app/actions/order.ts
'use server'
该操作会进行身份验证、字段校验、记录订单信息,再次验证/orders状态后进行重定向。auth()函数会读取请求中的Cookie,因此客户端无需传递令牌。
import { auth } from '@/lib/auth'
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'export async function createOrder(formData: FormData) {
// Auth is automatic — no need to pass session
const session = await auth()
if (!session?.user) throw new Error('Unauthorized') const item = formData.get('item') as string
const quantity = Number(formData.get('quantity')) // Validate
if (!item || quantity < 1) {
return { error: 'Invalid order data' }
} // Database write
const order = await db.order.create({
data: { item, quantity, userId: session.user.id },
}) // Invalidate cached data
revalidatePath('/orders') // Redirect after success
redirect(`/orders/${order.id}`)
}
请注意,redirect是通过抛出异常来实现的,因此绝不能在会吞掉错误的try块中调用它。该页面会导入此操作函数:
// app/shop/page.tsx
import { createOrder } from '@/app/actions/order'
并将其直接传递给表单的action属性:
export default function ShopPage() {
return (
<form action={createOrder}>
<input name="item" type="text" placeholder="Item name" />
<input name="quantity" type="number" defaultValue={1} />
<button type="submit">Order</button>
</form>
)
}
没有客户端状态、效应或fetch操作,而且表单甚至在JavaScript加载完成之前就会提交。
使用useActionState返回错误
useActionState(React 19)用于存储动作的最后一个返回值以及一个待处理标志:
'use client'
import { useActionState } from 'react'
import { createOrder } from '@/app/actions/order'
export function OrderForm() {
const [state, action, isPending] = useActionState(createOrder, null) return (
<form action={action}>
{state?.error && (
<p style={{ color: 'red' }}>{state.error}</p>
)}
<input name="item" />
<input name="quantity" type="number" />
<button type="submit" disabled={isPending}>
{isPending ? 'Ordering...' : 'Place Order'}
</button>
</form>
)
}
有一个需要注意的地方:当某个动作被useActionState封装时,它会将之前的状态作为第一个参数,而FormData作为第二个参数。上面所示的createOrder函数只接受formData参数,因此对于这个表单而言,该函数必须改为createOrder(prevState, formData)的形式。
使用useOptimistic实现乐观反馈
useOptimistic会在动作最终确定结果之前显示预期的结果:
'use client'
import { useOptimistic } from 'react'
import { toggleLike } from '@/app/actions/post'
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [optimisticLikes, setOptimistic] = useOptimistic(initialLikes) async function handleLike() {
setOptimistic(prev => prev + 1) // UI updates instantly
await toggleLike(postId) // Server call happens in background
} return (
<button onClick={handleLike}>
❤️ {optimisticLikes}
</button>
)
}
乐观更新机制必须在过渡或动作操作中执行。从普通的onClick事件中,需将相关代码包裹在startTransition函数内,否则React会发出警告,且乐观状态不会按预期工作。回滚相关的风险可在useOptimistic的五种失败模式中了解。
何时使用路由处理程序
这种带版本的接口展示了其优势:查询参数、CDN缓存以及API密钥认证功能。
// app/api/v1/products/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const category = searchParams.get('category') const products = await db.product.findMany({
where: category ? { category } : undefined,
}) return NextResponse.json({ products }, {
headers: {
'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
}
})
}export async function POST(request: NextRequest) {
const apiKey = request.headers.get('x-api-key')
if (apiKey !== process.env.API_KEY) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
} const body = await request.json()
const product = await db.product.create({ data: body })
return NextResponse.json({ product }, { status: 201 })
}
以下情况适合选择路由处理程序:
- 移动应用需要调用该接口;
- 第三方会发送Webhook通知;
- 响应需要独立的缓存头信息;
- 该接口属于公开的、带版本的API或API产品。
在生产环境中,应先验证 POST 请求体,而非直接将其未经检查地传递给数据库。
两者的对比
- 往返次数:两者都需从浏览器向服务器发送一个请求;均非服务器间直接传输。
- 客户端逻辑:无论哪种方式,逻辑都位于服务器端。
- 身份验证:操作会自动从 Cookie 中读取会话信息;而为其他客户端服务的处理程序则需在每次请求时检查令牌或密钥。
- 类型:操作与处理程序共享端到端的类型信息;处理程序则需要有类型的客户端或架构定义。
- 缓存:操作可通过
revalidatePath或标签来使缓存失效;处理程序则负责设置Cache-Control。 - 乐观 UI:操作功能内置在 React 的钩子中;处理程序则需手动实现。
在撰写本文时,Next.js也是从客户端逐个发送Server Actions的,因此它们更适合用于数据修改操作而非并行读取;请查阅最新文档。
决策检查清单
首先从决定大多数情况的关键问题入手:
Does an external system call this?
YES → API Route
然后再依次处理其余问题:
Is this triggered by a form submit or user action within your UI?
YES → Server ActionDo you need explicit HTTP caching headers?
YES → API RouteDo you want automatic auth context without passing tokens?
YES → Server ActionDo you need to call this from a mobile app?
YES → API RouteEverything else?
→ Server Action (less boilerplate)
需避免的两种错误
将webhook指向Server Action
Stripe会向已注册的URL发送数据,无法直接指定某个动作ID。
// ❌ Wrong — Server Actions can't receive arbitrary HTTP POSTs from Stripe
'use server'
export async function handleStripeWebhook() { ... }
webhook应放在Route Handler中,在那里可以读取原始请求体并验证签名。
// ✅ Correct
// app/api/webhooks/stripe/route.ts
export async function POST(request: NextRequest) { ... }
轻信动作输入的数据
由于任何动作都可以被直接调用,如果没有进行验证的删除动作会让任何人随意删除任意内容:
// ❌ Wrong — Server Actions are not inherently trusted
'use server'
export async function deletePost(postId: string) {
await db.post.delete({ where: { id: postId } }) // Anyone can call this!
}
解决方案是在写入数据前先验证会话信息及所有权,之后再重新进行验证。
// ✅ Correct — always validate auth + ownership
'use server'
export async function deletePost(postId: string) {
const session = await auth()
const post = await db.post.findUnique({ where: { id: postId } })
if (post?.userId !== session?.user?.id) throw new Error('Forbidden')
await db.post.delete({ where: { id: postId } })
revalidatePath('/posts')
}
还应明确拒绝缺少会话的情况。请参阅我们的文章每个 Server Action 中的授权机制,其中对此有深入探讨。
关键要点
- 每个 Server Action 都是一个可访问的端点;需在其内部进行身份验证、授权和数据校验。
- 通过
useActionState,该 Action 可首先获取之前的状态。 - 在状态转换过程中运行
useOptimistic进行更新。
相关阅读
- useOptimistic 回滚机制:Next.js Server Action 中的五种故障模式 — 通过五种经过测试的 Server Action 故障模式及可行的解决方案,了解为何 useOptimistic 会在不向用户说明故障原因的情况下自动恢复 UI。