前几章搭好的 BoardFlow 协作台已经有项目列表、任务详情和客户端导航。现在我们要接住页面以外的请求:移动端要读取项目,第三方服务要推送 Webhook,浏览器要上传附件,任务执行进度还要持续发送给用户。
很多教程会把这些需求统称为“写几个 API 路由”。真正写进生产项目后,难点却很少在 Response.json() 本身。你需要回答的是:谁可以调用?输入从哪里来?失败应该返回什么?重复请求会不会创建两份数据?缓存是否会把甲用户的数据交给乙用户?第三方重复投递时,系统能否安全地再处理一次?
本章使用 Next.js 16.1.6、App Router 和 Route Handlers。课程名称仍叫“API 路由”,但正文不会混入 Pages Router 的 pages/api、NextApiRequest 或 NextApiResponse。项目当前没有开启 Cache Components;缓存一节会先讲当前行为,再单独说明开启后的模型。

Route Handler 是 app 目录里的公开 HTTP 入口。浏览器、移动端、命令行和第三方服务只要能访问部署地址,就可以向它发送请求。它不是“只供自己页面使用的隐藏函数”,也不会因为文件放在 Next.js 项目里就自动获得登录保护。
你可以把它理解成应用的 HTTP 适配层:
HTTP 请求
→ 解析路径、查询参数、Header 与正文
→ 验证身份、权限和输入
→ 调用业务服务或数据访问层
→ 把业务结果翻译成状态码、Header 与响应正文这一层很适合做 BFF,也就是为前端需要整理数据的后端入口。它可以聚合多个数据源、过滤敏感字段、接收 Webhook、生成文件或返回流。但这不等于 Next.js 替代了所有后端设施。数据库、持久化队列、对象存储、定时任务、长时间计算和独立服务仍要按需求部署。
同一个 Next.js 应用里的 Server Component 已经在服务器上执行。它需要数据库数据时,应直接调用 DAL 或服务函数:
// app/workspace/projects/page.tsx
import { listProjectsForUser } from '@/lib/boardflow/projects'
import { requireUser } from '@/lib/auth/session'
export default async function ProjectsPage() {
const user = await requireUser()
const projects = await listProjectsForUser(user.id)
return <ProjectList projects={projects} />
}不要在这里再 fetch('http://localhost:3000/api/projects')。构建阶段未必有一台 HTTP 服务器正在监听;请求阶段也会平白多一次网络往返。Route Handler 应服务真正需要 HTTP 边界的调用者,Server Component 与它复用同一套服务和数据访问函数即可。
Route Handler 的 URL 是公开攻击面。客户端隐藏按钮、Layout 中做一次登录判断、或者把地址写得很难猜,都不能代替处理函数里的认证、授权和输入验证。
Route Handler 只能写在 app 目录的 route.ts 或 route.js 中,并且可以出现在任意嵌套层级。BoardFlow 的接口可以这样组织:
app/
└── api/
├── projects/
│ ├── route.ts
│ └── [projectId]/
│ ├── tasks/
│ │ └── [taskId]/
│ │ └── route.ts
│ └── events/
│ └── route.ts
└── integrations/
└── github/
└── webhook/
└── route.ts对应关系是:

你通过具名导出声明方法:
export async function GET() {
return Response.json({ ok: true })
}
export async function POST(request: Request) {
return Response.json({ received: true }, { status: 201 })
}支持的方法包括 GET、POST、PUT、PATCH、DELETE、HEAD 和 OPTIONS。请求的方法被 HTTP 识别、但文件没有实现时,Next.js 会返回 405 Method Not Allowed。如果定义了 GET 而没有单独定义 HEAD,Next.js 会自动用 GET 处理 HEAD,并在传输时省略正文。如果没有显式导出 OPTIONS,Next.js 也会自动生成 204 响应,并根据已实现与自动实现的方法填写 Allow。
这意味着方法分派不需要手写一个巨大的 switch (request.method)。只有当你要为跨域预检增加精确的 CORS Header 时,才通常显式实现 OPTIONS。
包括 GET 和 HEAD 在内,每个方法都可以按需接收 Request 参数;区别不在函数签名,而在 HTTP 语义。GET 与 HEAD 请求不应携带正文,需要筛选条件时使用路径或查询参数。
下面的结构有冲突:
app/reports/page.tsx → /reports
app/reports/route.ts → /reports页面和处理函数都想拥有 /reports。把接口放进 app/api/reports/route.ts,或者给下载资源增加更明确的子路径,例如 app/reports/export/route.ts。Layout 也不会像 React 组件那样包裹 Route Handler;访问接口时不会渲染页面树。
路由上下文中的 params 是 Promise。Next.js 会在开发、构建或类型生成时创建全局 RouteContext 类型,因此可以直接写路由字面量:
export async function GET(
_request: Request,
context: RouteContext<'/api/projects/[projectId]'>
) {
const { projectId } = await context.params
return Response.json({ projectId })
}方括号目录只负责提取字符串,不会证明这个 ID 合法,更不会证明当前用户属于该项目。后面仍要验证格式和资源权限。
下面的实验台允许你组合文件位置、URL 和 HTTP 方法,观察动态参数、自动 OPTIONS、405 与 page/route 冲突。
接口契约描述调用者可以依赖的行为:方法和 URL、输入位置、状态码、响应 Header、正文结构以及重试规则。先定契约,处理函数才不会在业务代码中边写边猜。
“幂等”不是“每次响应完全一样”,而是多次发送同一个请求,服务端预期效果与发送一次相同。例如第一次删除任务返回 204,第二次可能返回 404;目标最终都处于“不存在”状态,所以 DELETE 仍可以是幂等的。
POST 默认不幂等。用户双击“创建项目”、移动网络超时后自动重试,都可能产生两份记录。对这种接口可以接受 Idempotency-Key,并在数据库中把“用户 + 接口 + 幂等键”设为唯一键,同时保存请求指纹和第一次结果。相同键携带不同正文时应拒绝,而不是悄悄复用旧响应。
幂等记录必须存在共享、持久化存储中,并与业务写入放进可靠事务或等价机制。模块级 Map 在多实例、重启和冷启动后都会失效,只能用于演示,不能承担生产语义。

客户端需要机器可读的错误码,服务端日志需要请求 ID。它不需要数据库栈、SQL、Token 或第三方原始响应:
type PublicError = {
error: {
code: string
message: string
fields?: Record<string, string[]>
requestId: string
}
}
export function errorResponse(
status: number,
code: string,
message:
公开消息保持可行动但不过度暴露。例如返回“任务更新失败,请稍后重试”,同时在服务端按 requestId 记录经过脱敏的错误原因。不要把 error.stack 直接送给客户端,也不要把完整 Cookie、Authorization 或请求正文写入日志。
下面的契约实验台会根据身份、角色、Content-Type、正文和请求频率模拟一条处理管线。重点不是猜状态码,而是看请求在哪一道边界被拒绝。
Route Handler 默认使用 Web 标准 Request 与 Response。需要 Next.js 额外能力时,再把请求标成 NextRequest,它增加了方便操作 Cookie 的接口和已经解析好的 nextUrl。

import type { NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const pageText = request.nextUrl.searchParams.get('page') ?? '1'
const status = request.nextUrl.searchParams.get('status') ?? 'open'
const page = Number(pageText)
if (!
Number('12px') 会得到 NaN,而 parseInt('12px', 10) 会得到 12。分页参数通常应该拒绝带尾字符的输入,所以这里使用 Number 后再检查整数与范围。
查询字符串可能进入浏览器历史、服务器访问日志、分析系统和共享缓存。密码、访问令牌、一次性验证码等敏感数据不要放在 GET 查询参数里。
export async function POST(request: Request) {
const requestId = request.headers.get('x-request-id') ?? crypto.randomUUID()
const contentType = request.headers.get('content-type')
const idempotencyKey = request.headers.get('idempotency-key')
return Response.json(
{ requestId, contentType, hasIdempotencyKey:
传入的请求 ID 也要限制字符集和长度,或者直接由可信边缘层生成。不要把任意用户字符串原样放进日志,以免造成日志注入。
import type { NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const sessionCookie = request.cookies.get('boardflow_session')?.value
// 真实代码把它交给会话库验证签名、有效期与撤销状态
return Response.json({ hasSessionCookie: Boolean(sessionCookie) })
}拿到 Cookie 只说明浏览器带来一段字符串。服务端仍要验证会话,并继续检查用户是否属于请求中的项目。
需要在响应中设置 Cookie 时,可以使用 NextResponse:
import { NextResponse } from 'next/server'
export async function POST() {
const response = NextResponse.json({ signedOut: true })
response.cookies.set('boardflow_session', '', {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 0,
})
普通 JSON、文本和流优先使用标准 Response 就够了;需要 Next.js 的 Cookie 或重定向辅助能力时再使用 NextResponse。从 next/headers 导入的 cookies() 与 headers() 在 Next.js 16 中也是异步函数,headers() 得到的是只读视图;设置响应 Header 仍应构造新的 Response 或 Headers。
下面的写法无效:
const json = await request.json()
const text = await request.text() // Body 已被消费,会失败一个请求应根据 Content-Type 选择一种读取方式。确实需要让两个独立消费者读取时,可以在第一次读取前 request.clone(),但这会带来额外缓冲和内存成本,不能用来绕过大文件限制。
解析方法与媒体类型应一一对应。不要先盲目调用 json(),失败后再逐个尝试其他格式。
function mediaTypeOf(request: Request) {
return request.headers.get('content-type')?.split(';', 1)[0].trim().toLowerCase()
}
export async function POST(request: Request) {
if (mediaTypeOf(request) !== 'application/json') {
return
Content-Type 可以带 charset=utf-8,所以不能简单比较完整 Header 字符串。生产项目若要接受 application/*+json,应明确扩展规则,而不是让任意类型落入 JSON 解析。
const rawBody = await request.text()原始正文读取后,可以先计算签名,再 JSON.parse(rawBody)。顺序不能倒过来,因为解析并重新序列化会改变空白或字段表现,计算出的字节不再是发送方签名的内容。
export async function POST(request: Request) {
if (!mediaTypeOf(request)?.startsWith('multipart/form-data')) {
return new Response('只接受 multipart/form-data', { status: 415 })
}
const form = await request.formData()
const title = form.get('title')
客户端提供的 MIME 和扩展名都可以伪造。高风险上传还要检查真实文件签名、重新编码图片、随机化文件名并隔离公开访问。大文件不要先完整缓冲进 Route Handler;更常见的做法是校验权限后签发短期上传凭证,让浏览器直传对象存储。
export async function GET(request: Request) {
const accept = request.headers.get('accept') ?? 'application/json'
const tasks = await listPublicTasks()
if (accept.includes('text/csv')) {
return new Response(toCsv(tasks), {
headers: {
'Content-Type': 'text/csv; charset=utf-8'
这个简化判断足够说明思路。需要完整处理媒体类型权重时,应使用成熟解析库。只要同一 URL 会按 Accept 返回不同表示,就要通过 Vary: Accept 告诉共享缓存这些响应不能混用。
TypeScript 类型在编译后不会检查网络输入。即使函数签名写着 { name: string },调用者仍可以发送 null、数组或十万字符。项目已经安装 Zod,我们用它为创建项目定义运行时边界:
import { z } from 'zod'
export const createProjectSchema = z.object({
name: z.string().trim().min(2, '至少输入 2 个字符').max(80),
visibility: z.enum(['private', 'team']),
color: z.enum(['blue', 'green', 'orange']),
description: z.
strict() 会拒绝未声明字段,避免调用者以为某个敏感字段已经生效。是否允许额外字段要由版本兼容策略决定;重点是明确,而不是碰巧忽略。
const parsed = createProjectSchema.safeParse(input)
if (!parsed.success) {
return Response.json(
{
error: {
code: 'VALIDATION_FAILED',
message: '请检查提交内容',
fields: parsed.error.flatten().fieldErrors,
},
},
{ status: 422 }
)
}
const command = parsed.data验证通过也不代表可以直接拼 SQL。值使用参数化查询;排序列、表名等不能绑定的位置,先映射到服务端固定白名单:
const orderBy = {
created: 'created_at',
due: 'due_at',
priority: 'priority',
} as const
const column = orderBy[input.sort]
// userId、limit、offset 使用驱动提供的参数绑定。
// column 只来自上面的固定映射,不接受用户原文。
await db.query(
`SELECT id, name FROM tasks WHERE owner_id = $1 ORDER BY ${column} LIMIT $2 OFFSET $3`,
[userId, limit, offset]
)对 URL、Header、Cookie、Webhook 事件和数据库返回也保持同样态度:进入信任边界时验证一次,跨到另一个系统前再按对方契约整理一次。
我们先完成 /api/projects。处理函数负责 HTTP,@/lib/boardflow/projects 负责授权范围内的数据操作。
// app/api/projects/route.ts
import type { NextRequest } from 'next/server'
import { getSession } from '@/lib/auth/session'
import { listProjectsForUser } from '@/lib/boardflow/projects'
export async function GET(request: NextRequest) {
const session = await getSession(request)
if (!session) {
return Response.json(
{ error: { code: 'UNAUTHENTICATED'
listProjectsForUser 从入口就接收 userId,比“先查所有项目,再在 Route Handler 过滤”更稳妥。服务端不应把内部备注、成员邮箱、计费标识等字段整行序列化出去;DAL 可以返回专门的安全 DTO。
// 与上面的 GET 放在同一个 route.ts
import { createProjectSchema } from '@/lib/boardflow/schemas'
import { createProjectOnce } from '@/lib/boardflow/projects'
export async function POST(request: Request) {
const session = await getSession(request)
if (!session) {
return Response.json(
{ error: { code: 'UNAUTHENTICATED', message: '请先登录' } },
{ status: 401
createProjectOnce 应在持久化层原子地检查幂等键、请求指纹与业务写入。重复请求返回第一次的业务结果;键相同但正文不同返回 409。不要先查再插而不加唯一约束,那会在并发请求下产生竞态。
任务 URL 同时包含项目 ID 与任务 ID。每次操作都要证明任务属于该项目,并且用户拥有相应角色。
// app/api/projects/[projectId]/tasks/[taskId]/route.ts
import { z } from 'zod'
import { getSession } from '@/lib/auth/session'
import { deleteTaskForMember, updateTaskForMember } from '@/lib/boardflow/tasks'
const patchTaskSchema = z.object({
title: z.string().trim().min(1).max(120).optional(),
status: z.enum(['todo', 'doing',
这里为了聚焦资源逻辑,用 .catch(() => null) 把无法解析的 JSON 归入验证失败。若你的公开契约要严格区分 JSON 语法错误与字段语义错误,应沿用前一节的 400/422 两段处理。
export async function DELETE(request: Request, context: TaskContext) {
const session = await getSession(request)
if (!session) {
return new Response(null, { status: 401 })
}
const { projectId, taskId } = await context.params
const
204 表示没有正文。不要写 Response.json({ ok: true }, { status: 204 }),否则契约与传输语义互相矛盾。若客户端确实需要删除后的任务或下一步信息,就返回 200 并明确响应结构。

图中是一张检查清单,不是所有接口都必须机械照搬的固定执行顺序。方法、媒体类型和体积可以先做低成本拒绝;身份确认后,应尽早阻止无权用户进入昂贵查询。CORS 只决定浏览器能否读取跨域响应,不是授权步骤。
认证回答“你是谁”,会话回答“服务器如何在多次请求间记住这个身份”,授权回答“这个身份能否做当前操作”。三者不能合并成一句 if (token)。
若接口接受 Authorization: Bearer ...,应严格解析认证方案,并交给成熟的身份库验证签名算法、签发方、受众、过期时间与撤销策略。header.replace('Bearer ', '') 只是在截字符串,既没有证明 Token 真实,也没有检查它是否适用于当前接口。Token 验证通过后,仍要继续做项目和资源授权。
BoardFlow 可以在 DAL 中定义明确的能力:
type ProjectRole = 'viewer' | 'editor' | 'owner'
const permissions = {
viewer: new Set(['task:read']),
editor: new Set(['task:read', 'task:update']),
owner: new Set(['task:read', 'task:update', 'task:delete', 'member:manage']),
} satisfies Record<
真实查询还应把 projectId 与 actorId 放在同一条授权范围里,避免先查出任意任务,再忘记检查归属。对于多租户数据,最好让数据访问函数从参数和返回类型上就难以绕过租户边界。
敏感操作不要只在 Proxy 中拦截。Proxy 适合做快速的乐观检查和横切逻辑,Route Handler 或 DAL 仍要做靠近数据的权威校验。第 8 章会单独讲 Proxy。
登录、验证码、导出、Webhook 和昂贵查询都应考虑限流。限流键可以组合可信用户 ID、客户端标识和接口维度;不要只相信用户可伪造的 Header。触发限制时返回 429,并在能确定等待时间时提供 Retry-After。
多实例部署下,计数器必须放在共享存储或由网关、托管平台提供。服务端日志记录请求 ID、路由、结果码、耗时和经过筛选的业务标识,不记录完整凭证与正文。
下面的实验台把身份、角色、资源归属和请求频率组合在一起。你可以观察同一个 JSON 为什么会在不同边界得到不同状态码。
“客户端没有显示删除按钮”只改善体验。攻击者可以直接构造 DELETE 请求,所以服务器必须重新检查会话、角色、项目与任务的归属。
CORS 回答的是:浏览器是否允许某个来源的前端 JavaScript 读取响应。它不会验证用户身份,也不会阻止 curl、服务端程序或恶意脚本向接口发请求。
const allowedOrigins = new Set([
'https://app.boardflow.example',
'https://admin.boardflow.example',
])
function corsHeaders(request: Request) {
const origin = request.headers.get('origin')
if (!origin || !allowedOrigins.has(origin)) return null
return new Headers({
当浏览器使用 Cookie,或者 fetch 设置了 credentials: 'include',Access-Control-Allow-Origin 不能是 *,必须返回经过白名单确认的具体 Origin,并带 Access-Control-Allow-Credentials: true。实际 GET、POST 等响应也必须带相同 CORS Header,只给预检响应添加是不够的。
不要把请求中的 Origin 无条件原样反射回响应。那相当于任何网站都进入了白名单。Vary: Origin 则提醒共享缓存:不同 Origin 的响应 Header 不能混用。
用户访问恶意网站时,浏览器仍可能自动把 BoardFlow Cookie 带给 BoardFlow。即使恶意页面因 CORS 读不到结果,状态修改也可能已经发生。这就是为什么 CORS 不能代替 CSRF 防护。
Cookie 会话的变更请求通常组合使用:
SameSite Cookie,作为浏览器层的纵深防御。Origin,必要时再检查 Referer。若 API 只接受调用者主动放入 Header 的 Bearer Token,传统 Cookie 型 CSRF 风险较低,但 Token 存储、XSS、泄漏和权限范围仍需处理。不要把“不是 Cookie”理解成“没有安全问题”。
Webhook 与普通表单最大的区别是:请求来自另一个系统,而且发送方通常会重试。超时、重复和乱序是正常情况,不是偶发异常。

下面用 GitHub 风格的 HMAC SHA-256 签名说明流程。Node.js runtime 可以直接使用 node:crypto:
// app/api/integrations/github/webhook/route.ts
import { createHmac, timingSafeEqual } from 'node:crypto'
import { z } from 'zod'
import { enqueueGithubEventOnce } from '@/lib/boardflow/integrations'
export const runtime = 'nodejs'
const issueEventSchema = z.object({
action: z.enum(['opened', 'edited', 'closed', 'reopened']),
repository: z.object({
id: z.
验签必须发生在解析和处理正文之前。timingSafeEqual 要先比较长度,否则长度不同会抛错。示例只消费 issues,其他已通过签名的事件用 204 明确忽略,避免发送方为无须处理的事件反复告警。事件 ID 去重应使用数据库唯一约束或持久化消息系统,并保留足够长的重试窗口。
有些供应商把签名时间戳放进签名 Header。此时还要验证允许的时间偏差,降低旧请求被重放的风险。各家协议不同,应使用供应商官方库或严格按其文档实现,不能把 GitHub、Stripe 等 Header 混成一套“通用 Webhook”。
处理函数只完成验签、最小 Schema 检查、持久化或入队,然后尽快返回 2xx。调用第三方 API、生成报表等慢操作交给后台 worker。事件乱序时,消费者根据资源当前状态或事件版本作决定,不能假设网络到达顺序就是业务发生顺序。
普通 JSON 是“请求一次,响应结束”。任务导入进度会持续变化,更适合使用 Server-Sent Events。SSE 基于普通 HTTP,服务端单向推送文本事件,浏览器断线后可以自动重连。
// app/api/projects/[projectId]/events/route.ts
import { subscribeToProject } from '@/lib/boardflow/events'
import { getSession } from '@/lib/auth/session'
import { canReadProject } from '@/lib/boardflow/projects'
export const runtime = 'nodejs'
type EventsContext = RouteContext<'/api/projects/[projectId]/events'>
export async function GET(request: Request, context: EventsContext) {
每个 SSE 事件以空行结束。id 帮助重连后续传,retry 给浏览器建议重连间隔,以冒号开头的心跳注释可以降低空闲连接被中间层关闭的概率。
这个处理函数读取会话、Header 与取消信号,本来就要在请求时运行,不需要再写 dynamic = 'force-dynamic'。这也避免把旧的路由段开关误当成开启 Cache Components 后的缓存表达方式。
不要手动设置 Transfer-Encoding: chunked。HTTP/1.1、HTTP/2 和托管平台如何分帧并不相同,运行时会处理传输细节。你真正要控制的是内容类型、缓存、取消清理和事件格式。
浏览器原生 EventSource 不能像 fetch 那样随意设置 Authorization Header。常见选择是使用安全 Cookie、签发短期且范围受限的连接凭证,或改用 fetch 读取响应流。不要把长期 Token 放在查询字符串里。
SSE 仍会受到平台执行时限、代理缓冲和并发连接限制。部署前必须在真实环境测试;需要双向通信或长期连接时,也要确认托管形态是否适合 WebSocket 或独立实时服务。
下面的可靠性实验台分为 Webhook 与 SSE 两个页签。你可以制造重复投递、乱序、断线和重连,观察去重与事件 ID 如何恢复连续结果。
缓存最危险的误解是“GET 天生会缓存”或“写个 revalidate = 0 才是动态”。Next.js 16 的答案取决于项目是否开启 Cache Components。

当前 next.config.mjs 没有 cacheComponents: true。在这套模型中,Route Handlers 默认不缓存。读取 Cookie、Header、请求 URL 或其他运行时信息的接口自然应在请求时执行。
只有完全公开、确定性的 GET 才适合主动静态化:
// app/api/public/status-catalog/route.ts
export const dynamic = 'force-static'
export const revalidate = 300
export async function GET() {
const catalog = await readPublicStatusCatalog()
return Response.json({ data: catalog })
}POST、PATCH 等非 GET 方法不会因为和这个 GET 放在同一文件里就被缓存。对包含用户身份、权限或个性化内容的响应,不要使用 force-static。
开启 cacheComponents: true 后,GET Route Handler 会进入与 UI 相同的预渲染判断:只包含确定性静态代码时可以预渲染;读取请求 URL、Header、Cookie、正文、未缓存数据库或其他运行时数据时会退回请求时执行。非 GET 方法仍不会进入这套缓存。
此时 dynamic、revalidate 和 fetchCache 不再是主要表达方式。处理函数可以保持请求时执行,把确定、可共享的数据读取放入缓存函数:
import { cacheLife } from 'next/cache'
async function getPublicWorkflowTemplate(templateId: string) {
'use cache'
cacheLife({ stale: 60, revalidate: 300, expire: 3600 })
return loadPublicWorkflowTemplate(templateId)
}
export async function GET(
_request: Request,
context: RouteContext<'/api/public/templates/[templateId]'
'use cache' 不能直接写在 Route Handler 函数主体中。缓存函数的参数会参与缓存键,所以先验证、归一化参数,再传入缓存函数。读取请求正文、Cookie、Header、connection() 或非确定值会阻止整段预渲染,但处理函数仍可以调用独立的缓存数据函数。
无论使用哪套模型,先问四个问题:
下面的实验台会根据 Cache Components 开关和请求依赖给出执行方式。特别留意“数据函数可缓存”与“整个公开响应可共享”不是一回事。
Route Handler 可以调用 redirect(),也可以直接构造重定向响应。两种方式都要先验证目标地址。
普通 Route Handler 中,redirect() 使用 307;permanentRedirect() 使用 308。它们都会保留原方法和正文。把一个 POST 307 到另一个地址,浏览器仍可能向新地址发送 POST。
若你明确实现 Post/Redirect/Get,希望创建完成后转成 GET,可以直接返回 303:
export async function POST(request: Request) {
const project = await createProjectFromRequest(request)
const target = new URL(`/workspace/projects/${project.id}`, request.url)
return Response.redirect(target, 303)
}redirect() 通过抛出控制流异常结束当前执行,所以不要把它放进会捕获所有异常的 try/catch。先捕获业务错误,再在外部执行跳转。
function safeReturnTo(raw: string | null, request: Request) {
const fallback = new URL('/workspace', request.url)
if (!raw) return fallback
try {
const target = new URL(raw, request.url)
return target.origin === fallback.origin ? target :
如果直接 redirect(request.nextUrl.searchParams.get('returnTo')),攻击者可以制作一个看似来自 BoardFlow、登录后却跳到钓鱼站的链接。
“输入一个 URL,服务器帮你抓取预览”会让攻击者借服务器访问内网、云元数据、环回地址或管理面板。稳妥方案优先使用业务允许列表;必须支持开放 URL 时,至少验证协议、规范化主机、解析后的 IPv4/IPv6、内网和链路本地地址,并限制端口、重定向次数、响应大小与超时。
DNS 结果可能在校验后变化,重定向也可能从公网跳到内网,所以只检查字符串前缀不够。不要把传入请求的所有 Header 原封不动转发给上游,更不能把上游的敏感 Header 全部复制给浏览器。

Node.js 是 Route Handler 的默认 runtime,拥有完整 Node API,兼容范围也更广。Webhook 使用 node:crypto、数据库驱动依赖原生能力、文件处理依赖 Node 流时,优先保留默认值或明确写:
export const runtime = 'nodejs'Edge runtime 提供较小的 Web API 集合,靠近边缘节点,但并非所有 npm 包和 Node API 都可用:
export const runtime = 'edge'只有经过依赖兼容性、延迟和部署区域测量后,才应为了具体目标选择 Edge。启用 Cache Components 的路由不能使用 Edge runtime;Edge 也不支持 ISR。
Serverless 或多实例环境中,同一个用户的两个请求可能落到不同实例,实例也会随时重启。因此这些写法都不可靠:
共享状态放数据库、缓存服务或消息系统。大文件使用对象存储。后台任务先写入可靠队列,再由 worker 消费。
不同托管平台还有正文大小、执行时长、并发数和流式缓冲限制。框架 API 能运行不代表部署平台允许无限运行。上传、SSE、批量导出和第三方请求都要在真实部署中验证超时与取消。
如果项目改为静态导出,只有构建时能够确定并静态生成的 GET Route Handler 才适用。会话接口、Webhook、数据库写入和 SSE 都需要真正的服务器或函数运行时,不能靠导出目录继续工作。
这些能力都会运行服务器代码,但调用关系不同:
Server Action 的主要目的不是代替 GET 数据接口;它的调用会排队,适合 UI 发起的变更。Proxy 也不是最终资源处理器,它只能在入口做有限判断,敏感数据仍由 Route Handler、Server Action 或 DAL 完成权威授权。
判断方法很简单:先看调用者是谁,再看是否真的需要 HTTP 契约。服务器内部调用走普通函数;React UI 变更优先考虑 Server Action;外部客户端、Webhook、文件与流使用 Route Handler;路由前横切逻辑交给 Proxy。
浏览器页面跑通,只证明一条最顺利的路径可用。Route Handler 至少要覆盖方法、媒体类型、身份、权限、重复、并发和依赖失败。
curl -i 'http://localhost:3000/api/projects?page=1' \
-H 'Cookie: boardflow_session=REDACTED'创建接口同时检查状态码、Location 和幂等重放:
curl -i 'http://localhost:3000/api/projects' \
-X POST \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: demo-create-001' \
-H 'Cookie: boardflow_session=REDACTED' \
--data '{"name":"官网改版","visibility":"team","color":"blue"}'跨域接口还要单独测试预检:
curl -i 'http://localhost:3000/api/projects' \
-X OPTIONS \
-H 'Origin: https://app.boardflow.example' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,idempotency-key'Route Handler 接收标准 Request,单元测试可以直接构造请求,不必真的启动浏览器:
import { POST } from './route'
const request = new Request('https://boardflow.test/api/projects', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': 'test-001',
},
body: JSON.stringify({
name: '迁移文档',
visibility: 'team',
color: 'green',
认证和 DAL 应通过可替换依赖控制,避免测试连接真实生产服务。再用集成测试覆盖框架路由匹配、Cookie、流式响应和部署平台差异。
先测协议边界:未支持方法是否为 405,OPTIONS 是否正确,Content-Type 错误能否在解析前返回 415,204 是否真的没有正文。
再测身份和权限:未登录、只读成员、编辑者、项目所有者以及跨项目任务 ID 都要有明确结果。
接着测输入与并发:损坏 JSON、超长字段、额外字段、非法分页、旧版本 PATCH 和同一幂等键并发提交都不能绕过边界。
然后测外部失败:数据库超时、队列不可用、上游 500、Webhook 重复和签名错误应产生可预测响应,日志中只有脱敏细节。
Route Handler 的代码往往不长,但它站在不可信网络和内部数据之间。把方法、输入、权限、重试、缓存与失败语义写清楚,接口才不会只在“第一次手动调用成功”时看起来正常。下一章讨论 Proxy 时,我们会继续处理路由命中之前的横切逻辑;权威授权仍会留在最靠近数据的边界。
最后到真实部署验证上传大小、SSE 心跳与断线、代理缓冲、CORS、限流共享状态和冷启动。不要用本机表现替代平台验收。