- 发布日期
第 22 讲:Route Handlers 与 Server Actions:HTTP 接口的两套范式
Route Handlers 与 Server Actions 两套 HTTP 范式的设计差异与选型指南
第 12 讲我们详细讲过 Server Actions 的客户端 closure + 服务端 dispatch 机制;本讲把它放回与 Route Handlers 的对比里:何时用哪个、各自的 cache/revalidate/cookie/streaming 行为、错误处理差异、以及生产排障策略。
学习目标
读完本讲,你能:
- 区分 Route Handler、Server Action、Server Function(
'use server'模块)三种"服务端可调用代码"的边界。 - 看懂
AppRouteRouteModule.handle()的完整流程:method dispatch、static gen bailout、AsyncLocalStorage 注入、错误包装。 - 解释
HTTP_METHODS大小写敏感的内部原因,以及为什么export async function get()不工作。 - 在生产中根据场景选择正确的范式(数据 mutation vs JSON API vs streaming)。
- 排查 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 Handler | app/.../route.{ts,js} | 外部 HTTP(fetch/curl) | 公开 REST API、webhook、文件上传 |
| Server Action | 'use server' 函数 | 通过 <form action={fn}> 或 React useTransition | UI 触发的数据 mutation |
| Server Function (RSC) | app/.../page.tsx 的 server component | RSC 渲染时直接调用 | 数据读取 |
业务示例:
- 公开支付回调 → 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 白名单
export const HTTP_METHODS = [
'GET',
'HEAD',
'OPTIONS',
'POST',
'PUT',
'DELETE',
'PATCH',
] as const
重要:完全大小写敏感。export const get 不会被识别——dev 模式会 Log.error,生产直接 404。
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:派发
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:完整调用链
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.'
)
可以看到几个关键步骤:
- ensureUserland:拿到用户的 module(dev 下 Turbopack 每次都 require live module 以支持 HMR)。
- createRequestStore / createWorkStore:构造 AsyncLocalStorage,让 user code 里的
cookies()、headers()能拿到当前请求。 - 三层 ALS 嵌套:
actionAsyncStorage.run → workUnitAsyncStorage.run → workAsyncStorage.run,分别承载 action 上下文、work unit 隔离、整体 work store。 - 静态生成 bailout:如果 build 时尝试 prerender 这个 GET,但 module 里有
POST/PUT/DELETE/PATCH等 non-static method,会抛DynamicServerError,让 build 标记为 dynamic。 - 调 handler:把 request + context 传给用户函数,等响应。
错误包装:WrappedNextRouterError
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 时:
- swc plugin 看到
'use server',把addToCart换成一个 client reference(类似{$$typeof: 'react.server.reference', $$id: 'action_abc123#addToCart'})。 - HTML 里
<form action="...">实际是<form action="/?_next_action=action_abc123#addToCart">。 - 客户端表单 submit 时,浏览器 POST FormData 到那个 URL,附带
Next-Action: action_abc123#addToCartheader。
服务端:
- router-server 看到
Next-Actionheader → 走 action 路径。 app-render里handleAction拿 action ID 找 server reference manifest。- 反序列化 FormData → 调用
addToCart(formData)。 - 收集
revalidateTag等副作用 → 重渲染当前 page → 返回新的 RSC payload。 - 客户端 router 把新 payload 合入 reducer state。
五、Route Handler vs Server Action 的关键差异
| 维度 | Route Handler | Server 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?
值得注意的小细节:
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",
},
});
}
客户端用 EventSource 或 fetch + 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 不更新 | 忘了 revalidateTag 或 revalidatePath |
| 上传文件 413 Payload Too Large | experimental.serverActions.bodySizeLimit 调大;或改用 Route Handler |
| CORS preflight 失败 | 在 Route Handler 里加 OPTIONS handler |
调用 Server Action 报 Failed to fetch | 同源策略;从其它 origin 无法直接调 server action |
req.json() 抛 SyntaxError | request 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/ 提供:
/api/todos(Route Handler GET/POST):纯 REST API/api/sse(Route Handler streaming):SSE 流式响应/todosPage + Server Action:表单 mutation/todos/listServer 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 必须大写
十六、本讲小结
- Route Handler = HTTP API 入口,命名导出
GET/POST/...,大小写敏感。 - Server Action =
'use server'函数,绑定到 React form / transition,自动序列化 + revalidate + payload 合入。 - 两者底层都跑在
AppRouteRouteModule.handle或app-render.handleAction,共享actionAsyncStorage/workAsyncStorage三层 ALS。 - 决策:外部 API 用 Route Handler,UI mutation 用 Server Action。
- 流式响应必须用 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 集成等。