发布日期

第 22 讲:Route Handlers 与 Server Actions:HTTP 接口的两套范式

Route Handlers 与 Server Actions 两套 HTTP 范式的设计差异与选型指南

第 12 讲我们详细讲过 Server Actions 的客户端 closure + 服务端 dispatch 机制;本讲把它放回与 Route Handlers 的对比里:何时用哪个、各自的 cache/revalidate/cookie/streaming 行为、错误处理差异、以及生产排障策略。

学习目标

读完本讲,你能:

  1. 区分 Route Handler、Server Action、Server Function('use server' 模块)三种"服务端可调用代码"的边界。
  2. 看懂 AppRouteRouteModule.handle() 的完整流程:method dispatch、static gen bailout、AsyncLocalStorage 注入、错误包装。
  3. 解释 HTTP_METHODS 大小写敏感的内部原因,以及为什么 export async function get() 不工作。
  4. 在生产中根据场景选择正确的范式(数据 mutation vs JSON API vs streaming)。
  5. 排查 Route Handler / Server Action 的常见生产问题:未触发 revalidate、误命中 cache、CORS 问题、Form Data 解析。

本讲对应代码:

  • packages/next/src/server/route-modules/app-route/module.ts(Route Handler)
  • packages/next/src/server/web/http.ts(HTTP_METHODS 定义)
  • packages/next/src/server/app-render/action-handler.ts(Server Actions runtime)

一、三种"服务端可调用"机制

App Router 提供三套并存的"服务端可调用"机制,常被混淆:

名称文件位置调用方式主要用途
Route Handlerapp/.../route.{ts,js}外部 HTTP(fetch/curl)公开 REST API、webhook、文件上传
Server Action'use server' 函数通过 <form action={fn}> 或 React useTransitionUI 触发的数据 mutation
Server Function (RSC)app/.../page.tsx 的 server componentRSC 渲染时直接调用数据读取

业务示例:

  • 公开支付回调 → Route Handler
  • 用户点击"加入购物车" → Server Action
  • 商品详情页面 SSR 时读 SKU → Server Function(直接 async/await)

二、Route Handler 的内部实现

文件结构:

app/api/cart/
  ├─ route.ts
  └─ [itemId]/
     └─ route.ts

route.ts export 命名 HTTP 方法函数:

// app/api/cart/route.ts
export async function GET(req: Request) { ... }
export async function POST(req: Request) { ... }
export const PUT = async (req: Request) => { ... }

Next.js 把这些 export 注册成 _methods 表,运行时按 req.method 派发。

HTTP_METHODS 白名单

13:packages/next/src/server/web/http.ts
export const HTTP_METHODS = [
  'GET',
  'HEAD',
  'OPTIONS',
  'POST',
  'PUT',
  'DELETE',
  'PATCH',
] as const

重要:完全大小写敏感。export const get 不会被识别——dev 模式会 Log.error,生产直接 404。

367:packages/next/src/server/route-modules/app-route/module.ts
    if (process.env.NODE_ENV === 'development') {
      const lowercased = HTTP_METHODS.map((method) => method.toLowerCase())
      for (const method of lowercased) {
        if (method in userland) {
          Log.error(
            `Detected lowercase method '${method}' in '${
              this.resolvedPagePath
            }'. Export the uppercase '${method.toUpperCase()}' method name to fix this error.`
          )
        }
      }

resolveHandler:派发

396:packages/next/src/server/route-modules/app-route/module.ts
  private resolveHandler(method: string): AppRouteHandlerFn {
    // Prevent RCE: only allow recognized HTTP methods.
    if (!isHTTPMethod(method)) return () => new Response(null, { status: 400 })

    return this._methods[method]
  }

注意注释 Prevent RCE:恶意请求带方法 __proto__ 不会从 _methods 拿到值,直接 400。

handle:完整调用链

870:packages/next/src/server/route-modules/app-route/module.ts
  public async handle(
    req: NextRequest,
    context: AppRouteRouteHandlerContext
  ): Promise<Response> {
    await this.ensureUserland()
    // ...
    const handler = liveUserland
      ? this.resolveHandlerFromUserland(req.method, liveUserland)
      : this.resolveHandler(req.method)

    const staticGenerationContext: WorkStoreContext = {
      page: this.definition.page,
      renderOpts: context.renderOpts,
      buildId: context.sharedContext.buildId,
      // ...
    }

    const userland = liveUserland ?? this._userland

    staticGenerationContext.renderOpts.fetchCache = userland.fetchCache

    const actionStore: ActionStore = {
      isAppRoute: true,
      isAction: getIsPossibleServerAction(req),
    }

    const implicitTags = await getImplicitTags(...)

    const requestStore = createRequestStoreForAPI(req, req.nextUrl, implicitTags, undefined, context.previewProps)
    const workStore = createWorkStore(staticGenerationContext)

    const response: unknown = await this.actionAsyncStorage.run(
      actionStore,
      () =>
        this.workUnitAsyncStorage.run(requestStore, () =>
          this.workAsyncStorage.run(workStore, async () => {
            // ...
            const hasNonStatic = liveUserland
              ? hasNonStaticMethods(liveUserland)
              : this._hasNonStaticMethods
            if (hasNonStatic) {
              if (workStore.isStaticGeneration) {
                const err = new DynamicServerError(
                  'Route is configured with methods that cannot be statically generated.'
                )

可以看到几个关键步骤:

  1. ensureUserland:拿到用户的 module(dev 下 Turbopack 每次都 require live module 以支持 HMR)。
  2. createRequestStore / createWorkStore:构造 AsyncLocalStorage,让 user code 里的 cookies()headers() 能拿到当前请求。
  3. 三层 ALS 嵌套actionAsyncStorage.run → workUnitAsyncStorage.run → workAsyncStorage.run,分别承载 action 上下文、work unit 隔离、整体 work store。
  4. 静态生成 bailout:如果 build 时尝试 prerender 这个 GET,但 module 里有 POST/PUT/DELETE/PATCH 等 non-static method,会抛 DynamicServerError,让 build 标记为 dynamic。
  5. 调 handler:把 request + context 传给用户函数,等响应。

错误包装:WrappedNextRouterError

packages/next/src/server/route-modules/app-route/module.ts
export class WrappedNextRouterError {

如果用户 handler 抛 notFound() / redirect() / forbidden(),会被这个包装类捕获,转成 HTTP 404/307/403 响应。

三、Route Handler 与 cache 的耦合

// 默认行为
export async function GET() { return Response.json({...}) }
// 在 build 时被 prerender → 返回静态 JSON

// 强制 dynamic
export const dynamic = 'force-dynamic'
export async function GET(req: NextRequest) {
  const ip = req.headers.get('x-forwarded-for')  // 用了 headers → 自动 dynamic
  return Response.json({ ip })
}

判定规则:

  • 用了 cookies() / headers() / req.url 的 search params / req.formData() → 自动 dynamic。
  • 显式 export const dynamic = 'force-static' → 强制静态。
  • 默认 GET/HEAD:尝试 prerender;其它 method 永远 dynamic。

业务陷阱:你写了一个 /api/products 想做 ISR,但忘了 dev 模式永远是 dynamic,dev 测试时一切正常;生产 build 时 prerender 出 build 时刻的快照,新增商品要等下次部署才更新。修复:加 export const revalidate = 60

四、Server Actions:另一条路径

回顾第 12 讲。Server Action 的 HTTP 入口不是 Route Handler,而是 RSC 渲染管线的一个特殊 path:

// app/cart/page.tsx
import { addToCart } from './actions'

export default function Cart() {
  return <form action={addToCart}>...</form>
}

// app/cart/actions.ts
'use server'
export async function addToCart(formData: FormData) {
  // mutation logic
  revalidateTag('cart')
}

build 时:

  1. swc plugin 看到 'use server',把 addToCart 换成一个 client reference(类似 {$$typeof: 'react.server.reference', $$id: 'action_abc123#addToCart'})。
  2. HTML 里 <form action="..."> 实际是 <form action="/?_next_action=action_abc123#addToCart">
  3. 客户端表单 submit 时,浏览器 POST FormData 到那个 URL,附带 Next-Action: action_abc123#addToCart header。

服务端:

  1. router-server 看到 Next-Action header → 走 action 路径。
  2. app-renderhandleAction 拿 action ID 找 server reference manifest。
  3. 反序列化 FormData → 调用 addToCart(formData)
  4. 收集 revalidateTag 等副作用 → 重渲染当前 page → 返回新的 RSC payload。
  5. 客户端 router 把新 payload 合入 reducer state。

五、Route Handler vs Server Action 的关键差异

维度Route HandlerServer Action
调用方式任何 HTTP client(curl/fetch/SDK)仅 React form / useTransition
URL 暴露显式(/api/cart隐藏在 form action 里、附 Next-Action header
输入解析用户自己用 req.json() / req.formData()React + Next.js 自动反序列化
返回值Response 对象任意可序列化值 + RSC payload 自动合入
错误处理自己包装 Response.json({error}, {status:400})throw → React error boundary 显示
缓存 invalidation必须显式调 revalidateTag同上
流式完全支持(ReadableStream)不直接支持流式返回
CORS / 鉴权完全可控默认绑定到同源
与 RSC 状态联动需要客户端手动 router.refresh()自动合入 RSC payload
适合场景公开 API、webhook、文件上传表单提交、数据 mutation、UI 操作

决策树

  • 外部要调(移动端、第三方 webhook、curl 文档)→ Route Handler
  • 用户点按钮提交表单 → Server Action(最少代码,最好的 DX)
  • 既要 form 提交又要外部能 POST 同样行为 → Route Handler,前端用 fetch() 显式调

六、Server Action 也走 Route Module?

值得注意的小细节:

842:packages/next/src/server/route-modules/app-route/module.ts
const actionStore: ActionStore = {
  isAppRoute: true,
  isAction: getIsPossibleServerAction(req),
}

isAction 判断的是请求是否带 Next-Action header。即使是 Route Handler,也会在 actionStore 上记录 isAction,让内部 hook(如 revalidatePath)知道当前是否在 action 上下文。

七、流式响应:Route Handler 的强项

Server Action 的返回值必须是可序列化的(会被打成 RSC payload 的一部分),所以不能直接返回 ReadableStream 给用户。要做 AI 流式 / Server-Sent Events,必须用 Route Handler:

// app/api/chat/route.ts
export const runtime = "edge";

export async function POST(req: Request) {
  const { messages } = await req.json();

  const stream = new ReadableStream({
    async start(controller) {
      const encoder = new TextEncoder();
      for await (const chunk of openai.chat.completions.create({
        messages,
        stream: true,
      })) {
        const text = chunk.choices[0]?.delta?.content ?? "";
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ text })}\n\n`),
        );
      }
      controller.close();
    },
  });

  return new Response(stream, {
    headers: {
      "Content-Type": "text/event-stream",
      "Cache-Control": "no-cache",
      Connection: "keep-alive",
    },
  });
}

客户端用 EventSourcefetch + getReader

const res = await fetch("/api/chat", {
  method: "POST",
  body: JSON.stringify({ messages }),
});
const reader = res.body!.pipeThrough(new TextDecoderStream()).getReader();
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  console.log(value);
}

八、文件上传:Server Action vs Route Handler

Server Action 支持 FormData:

"use server";
export async function upload(formData: FormData) {
  const file = formData.get("file") as File;
  await uploadToS3(file);
}

Route Handler 也行,但要自己解析:

export async function POST(req: Request) {
  const formData = await req.formData();
  const file = formData.get("file") as File;
  // ...
}

差异:

  • Server Action 默认 1MB 限制(通过 experimental.serverActions.bodySizeLimit 调)。
  • Route Handler 默认 4MB(Vercel)/取决于 host。
  • Server Action 走 RSC payload 反序列化,附带 client reference 校验,多了几毫秒。
  • 大文件(视频、镜像)建议直传 S3 + presigned URL,前端 fetch S3,不走 Next.js server。

九、cookies / headers 写入

Server Action 可以直接 cookies().set(...)

"use server";
import { cookies } from "next/headers";
export async function login(token: string) {
  cookies().set("token", token, { httpOnly: true, secure: true });
}

Route Handler 通过 NextResponse

import { NextResponse } from "next/server";
export async function POST(req: Request) {
  const res = NextResponse.json({ ok: true });
  res.cookies.set("token", "...", { httpOnly: true });
  return res;
}

陷阱:cookies() 在 Route Handler 里也能用,但只能;要写必须返回 NextResponse 或在 Server Action 里写。

十、revalidate 副作用

两边都能调用 revalidateTag / revalidatePath

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

// Route Handler
export async function POST() {
  await db.products.create(...)
  revalidateTag('products')   // 标记带 tag 'products' 的所有 cache 失效
  revalidatePath('/products') // 标记这个路径的 prerender 失效
  return Response.json({ ok: true })
}

副作用在 response 返回后异步生效。Server Action 的 revalidate 会自动触发当前页面 RSC 重渲并合入 payload;Route Handler 不会——客户端需要自己 router.refresh() 或导航。

十一、错误处理对比

Route Handler

完全自己控制:

export async function POST(req: Request) {
  try {
    const body = await req.json();
    if (!body.name) {
      return Response.json({ error: "name required" }, { status: 400 });
    }
    // ...
  } catch (err) {
    return Response.json({ error: "internal" }, { status: 500 });
  }
}

未捕获的异常:

  • 通过 WrappedNextRouterError 检查 redirect / notFound / forbidden → 转 HTTP 响应
  • 其它异常 → 500 + 服务端日志

Server Action

抛出异常 → React error boundary 显示:

// app/cart/error.tsx
"use client";
export default function ErrorBoundary({ error }: { error: Error }) {
  return <div>购物车操作失败:{error.message}</div>;
}

Server Action 的 error 在 dev 下会带堆栈、生产是 digest 字符串。

十二、CORS / 鉴权

Route Handler 完全自由:

export async function OPTIONS() {
  return new Response(null, {
    headers: {
      "Access-Control-Allow-Origin": "https://my-app.com",
      "Access-Control-Allow-Methods": "GET, POST",
      "Access-Control-Allow-Headers": "Content-Type, Authorization",
    },
  });
}

export async function POST(req: Request) {
  const auth = req.headers.get("authorization");
  if (!verify(auth)) return new Response("Unauthorized", { status: 401 });
  // ...
}

Server Action 自带 CSRF 保护(仅同源 origin 才能调),不需要写 CORS。

十三、生产排障实战清单

现象排查
Route Handler 返回旧数据revalidate 配置错 / dynamic = 'force-static' 误用
export const get 不工作大小写错;必须 GET
cookies().set is not a function在 Server Component 里调;只能 Route Handler / Server Action
Server Action 触发但 UI 不更新忘了 revalidateTagrevalidatePath
上传文件 413 Payload Too Largeexperimental.serverActions.bodySizeLimit 调大;或改用 Route Handler
CORS preflight 失败在 Route Handler 里加 OPTIONS handler
调用 Server Action 报 Failed to fetch同源策略;从其它 origin 无法直接调 server action
req.json() 抛 SyntaxErrorrequest body 不是 JSON;用 try/catch
第三方 webhook 收不到响应在 Vercel 部署时可能命中 cache;加 dynamic = 'force-dynamic'
Next-Action header missing直接 curl Server Action URL;不允许,必须通过 React form/transition

十四、性能对比与开销

路径额外开销
Route Handler~1ms(runtime overhead)+ 业务时间
Server Action~5-10ms(RSC payload 序列化)+ 业务时间
Server Function(RSC 渲染中)0 额外(融合在 RSC stream)

结论:单次调用差异微小,可忽略;但高频 mutation 场景下,Server Action 的批处理特性(与 RSC payload 合并)反而能减少 round-trip。

十五、配套 fixture:四种风格对照

fixtures/lecture-22/ 提供:

  1. /api/todos(Route Handler GET/POST):纯 REST API
  2. /api/sse(Route Handler streaming):SSE 流式响应
  3. /todos Page + Server Action:表单 mutation
  4. /todos/list Server Function:纯 RSC 数据读取

启动:

cd learning/nextjs-40-lectures/fixtures/lecture-22
pnpm install
pnpm dev
# http://localhost:3022

实验:

# 1. Route Handler
curl http://localhost:3022/api/todos
curl -X POST http://localhost:3022/api/todos -H 'Content-Type: application/json' -d '{"text":"hello"}'

# 2. SSE
curl -N http://localhost:3022/api/sse

# 3. Server Action(在浏览器里点表单按钮)

# 4. Lowercase method 错误演示
curl -X get http://localhost:3022/api/todos
# 仍可,因为 HTTP method 在传输层不区分大小写,但用户 export 必须大写

十六、本讲小结

  1. Route Handler = HTTP API 入口,命名导出 GET/POST/...,大小写敏感。
  2. Server Action = 'use server' 函数,绑定到 React form / transition,自动序列化 + revalidate + payload 合入。
  3. 两者底层都跑在 AppRouteRouteModule.handleapp-render.handleAction,共享 actionAsyncStorage / workAsyncStorage 三层 ALS。
  4. 决策:外部 API 用 Route Handler,UI mutation 用 Server Action。
  5. 流式响应必须用 Route Handler;Server Action 返回值需可序列化。

阶段三完结 → 进入阶段四

至此第 15-22 讲(阶段三:渲染管线 RSC/SSR/PPR)全部完成。从下一讲(第 23 讲)开始进入阶段四:构建、打包与优化。

下讲预告:第 23 讲:next build 全流程:从 entry 收集到 nft.json。我们会拆 packages/next/src/build/index.ts 的 main 函数:page entries 是怎么收集到的、static-paths 调度、dev manifest 写出、tracing 与 Sentry 集成等。