首页 / 文章 / 服务器动作还是路由处理程序?Next.js 16的决策指南

服务器动作还是路由处理程序?Next.js 16的决策指南

了解在何种情况下 Next.js 16 中的 mutation 应该放在 Server Action 中,又在何种情况下需要 Route Handler,并通过针对表单、错误处理、乐观更新和 webhook 的修正示例进行说明。

1558 词

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 进行更新。

相关阅读

  • 为何 Next.js Server Actions 需要在每个函数体中进行检查授权 —— 通过一个详细的账户劫持案例,展示了未经身份验证的 Next.js Server Actions 如何暴露特权操作,以及应在何处进行授权检查以阻止此类行为。
  • React 19 升级指南:哪些新 API 可替代现有的临时解决方案 —— 对 use、Server Actions、useOptimistic 以及 React Compiler 的实际应用进行探讨,指出相关注意事项,并制定优先采用哪些 React 19 变更的方案。
  • 在 Next.js App Router 页面中如何划分服务器端与客户端边界 —— 一种实用的 React Server Components 思维模型:各部分在何处运行、博客文章页面如何拆分为服务器端和客户端部分,以及应遵循的导入规则。
  • Next.js 还是纯 React?如何根据项目需求选择框架 —— 一种以需求为导向的方法,用于判断新的 React 项目是否需要 Next.js,涵盖了 SEO、混合渲染、托管限制、路由需求以及团队经验等因素。
  • Next.js 应用中路由路径的单一真实来源 — 了解类型化的中央路由模块如何替代 Next.js 中分散的字符串路径,以及用于动态片段和分组路由导出的辅助函数。
  • 为什么 WebSockets 会在 Next.js API 路由中卡住以及如何通过自定义服务器解决该问题 — 了解为何 pages/api 路由中的 WS 服务器无法完成握手过程,以及如何使用自定义的 Next.js 服务器自行处理升级事件。