一个面向 Next.js App Router Route Handler 的可组合请求基础设施。
GitHub:github.com/tech-zjf/ne…
npm:www.npmjs.com/package/nex…
这两年 AI 和 Vibe Coding 让“把一个 Next.js 全栈功能做出来”越来越快。
身边常见两种架构:一部分团队采用 Monorepo,前端使用 Next.js、后端使用 NestJS;但更多中小团队、AI 产品和独立开发者,直接使用 Next.js 做全栈——页面、Server Action、Route Handler、数据库访问都在同一个项目。
这条路线很高效,我自己也很喜欢。
问题不在 Next.js,而在项目进入中后期之后:Route Handler 很容易变成所有横切逻辑的收容所。
鉴权 → 权限 → 解析 Body → 校验 → 调用 Service → try/catch → 统一响应 → 日志
单个接口看起来没问题;但接口越来越多后,Code Review 会越来越痛苦:本来想看业务逻辑,却要先穿过一层层重复的基础设施代码。
这不是一个“为了开源而造”的包
最开始,我只是想让自己的 Next.js 项目更好维护。
我对其中一个已经上线的项目做了只读审查,范围只包含 app/api:
- 430 个 Route Handler,约 52,783 行;
- 203 个 Route 直接调用
request.json(); - 413 个 Route 包含
try/catch; - 423 个 Route 手工构造
NextResponse.json(); - 357 个 Route 自己获取当前用户或认证上下文。
这些数字不代表“430 个接口都应该套一层抽象”。流式响应、上传、Webhook、跳转、复杂任务编排,本来就更适合保留原生 Route Handler。
但它确实说明了一件事:鉴权、错误映射、统一响应、Request ID、日志、参数解析这类逻辑,已经在 Route 层大量重复了。
所以我先在自己的项目里,把一部分重复度高的 JSON API 按这个思路重构了一遍。
经过实际使用后,接口行为更稳定了;更直接的感受是,Route 文件的阅读路径变清楚了不少:以前要先看认证、异常和响应模板,现在可以更快定位“这个接口究竟做什么业务”。
于是我把这部分通用能力抽出来:next-route-kit。
它未必适合所有项目,但如果你的 Next.js 项目也有类似问题,希望它能解决其中一部分。
一个真实且常见的 Route 长什么样
下面是从真实项目抽出的结构,所有业务名、接口名和内部实现细节均已脱敏:
// app/api/workspaces/[workspaceId]/records/route.ts
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ workspaceId: string }> },
) {
try {
// 1. 鉴权
const auth = await getCurrentAuth(request)
if (!auth) {
return NextResponse.json(API_RESPONSE.UNAUTHORIZED, {
status: 401,
})
}
// 2. 路由参数
const { workspaceId } = await params
// 3. 权限
const canWrite = await WorkspaceService.canWrite(
auth.userId,
workspaceId,
)
if (!canWrite) {
return NextResponse.json(API_RESPONSE.FORBIDDEN, {
status: 403,
})
}
// 4. 解析和校验
const raw = (await request.json()) as Partial<CreateRecordInput>
const input = parseCreateRecord(raw)
// 5. 业务
const record = await RecordService.create({
workspaceId,
operatorId: auth.userId,
...input,
})
// 6. 成功响应
return NextResponse.json({
...API_RESPONSE.SUCCESS,
data: { record },
})
} catch (error) {
console.error('Create record failed:', error)
// 7. 异常响应
return NextResponse.json(API_RESPONSE.INTERNAL_SERVER_ERROR, {
status: 500,
})
}
}
这段代码本身没有错,接口不多时也很直观。
问题在于,随着接口增加,认证、成员权限、JSON 解析、参数校验、响应结构和异常处理会复制到每个文件。不同开发者再稍微写出不同风格:
{ code: 0, msg: 'success', data: {} }
{ code: 'OK', message: 'success', data: [] }
{ error: 'Forbidden' }
前端随后不得不同时判断 HTTP Status、业务码和不稳定的数据结构;同一个错误可能弹全局 Toast,也可能被页面局部再处理一次。
真正难维护的不是某一行代码,而是接口契约和错误策略开始分叉。
社区已经有方案,为什么还要做一个?
社区并不缺工具,只是它们解决的是不同层的问题。
- next-safe-action 很适合 Server Actions:它提供 middleware、输入校验和客户端调用链路。
- Hono 是成熟的 Web 标准路由框架,可以挂载到 Next.js 的 catch-all Route 中。
- next-connect 提供 Next.js 的方法路由与 middleware 组合。
它们都不是“有问题”,只是和我当时的需求边界不完全一致。
我的诉求更窄:
不替换 Next.js 的文件路由;保留原生
Request/Response;只把重复的请求级策略抽到显式、不可变、可组合的 Factory 作用域中。
这也是 next-route-kit 的边界。
重构后:业务接口只保留业务阅读路径
先在普通服务端模块中定义共享策略:
// src/server/routes.ts
import {
ApiException,
apiResponsePlugin,
createRoute,
unauthorized,
type AnyRouteContext,
type Guard,
type RouteMiddleware,
} from 'next-route-kit'
// 业务项目自己维护业务码;包不替你定义行业语义。
export const ApiCode = {
OK: { code: 'OK', msg: 'success' },
UNAUTHORIZED: {
code: 'UNAUTHORIZED',
msg: 'Sign in required',
status: 401,
},
FORBIDDEN: {
code: 'FORBIDDEN',
msg: 'Permission denied',
status: 403,
},
INTERNAL_ERROR: {
code: 'INTERNAL_ERROR',
msg: 'Internal server error',
},
} as const
type AppLocals = {
requestId: string
userId?: string
}
type AppContext = AnyRouteContext<AppLocals>
const requestContext: RouteMiddleware<AppContext> = {
name: 'request-context',
use(context, next) {
context.locals.requestId =
context.request.headers.get('x-request-id') ?? crypto.randomUUID()
return next()
},
}
const requireUser: Guard<AppContext> = {
name: 'require-user',
async canActivate(context) {
// 替换为项目自己的认证实现。
const session = await getSessionFromRequest(context.request)
if (!session) {
throw unauthorized()
}
context.locals.userId = session.userId
return true
},
}
// 所有从 apiRoute 派生的 Route 都继承这些策略。
export const apiRoute = createRoute<AppLocals>({
middleware: [requestContext],
plugins: [
apiResponsePlugin({
success: ApiCode.OK,
systemError: ApiCode.INTERNAL_ERROR,
}),
],
})
// 认证 Route 是 apiRoute 的不可变子作用域。
export const authenticatedRoute = apiRoute.extend({
guards: [requireUser],
})
然后业务接口变成:
// app/api/workspaces/[workspaceId]/records/route.ts
import { jsonBody } from 'next-route-kit'
import { authenticatedRoute } from '@/src/server/routes'
type RouteParams = { workspaceId: string }
type CreateRecordInput = { title: string; content: string }
export const POST = authenticatedRoute<RouteParams, CreateRecordInput>({
// 只有确实需要时才声明自动 JSON 解析。
body: jsonBody<CreateRecordInput>(),
handler: async (_request, { params, body, locals }) => {
const canWrite = await WorkspaceService.canWrite(
locals.userId!,
params.workspaceId,
)
if (!canWrite) {
throw new ApiException(ApiCode.FORBIDDEN)
}
const record = await RecordService.create({
workspaceId: params.workspaceId,
operatorId: locals.userId!,
...body,
})
return { record }
},
})
现在 Code Review 的阅读路径变成:
这个接口需要登录
→ 读取 workspaceId 和 body
→ 判断是否有写权限
→ 创建记录
→ 返回结果
控制流没有消失,只是把“所有接口都一样的部分”放到了一个可见、可测试的共享位置。
为什么 Handler 仍然保留原生 Request
我不希望为了“优雅”而创造新的认知负担。
所以 Handler 的第一个参数始终是原生 Web Request:
export const GET = authenticatedRoute({
handler: async (request, { locals }) => {
const url = new URL(request.url)
return RecordService.list({
operatorId: locals.userId!,
page: Number(url.searchParams.get('page') ?? 1),
})
},
})
params:Next.js 动态路由参数;body:仅在声明jsonBody()后提供;query:仅在声明query()后提供;locals:Middleware / Guard 为当前请求写入的共享数据;request:仍然是你熟悉的原生请求对象。
没有强制的 args 大对象,也没有语义模糊的 state。
统一响应不是“统一 HTTP 状态码”
很多项目会混淆两件事:
- HTTP Status:描述协议层结果,如 401、403、409、500;
- 业务码:描述前端稳定可分支的业务语义,如
QUOTA_EXCEEDED、PLAN_REQUIRED。
next-route-kit 的 apiResponsePlugin() 是可选插件。启用后,普通对象和业务异常都会在 Route 边界收敛为:
{
code: 'OK',
msg: 'success',
data: {
// 永远是对象,方便后续扩展
},
}
业务层只需抛出类型化异常:
if (remainingQuota < 1) {
throw new ApiException(ApiCode.QUOTA_EXCEEDED, {
data: { remainingQuota },
})
}
HTTP Status 仍然保留,例如配额冲突可以是 409;但前端不需要再靠字符串 message 猜业务状态:
if (payload.code === ApiCode.QUOTA_EXCEEDED.code) {
openUpgradeDialog()
}
包不会替应用决定 Toast、弹窗还是页面错误态——那是产品层的职责。它解决的是让前端拿到稳定且统一的接口契约。
请求链路与 NestJS 的关系
这个项目借鉴的是 NestJS 中“横切关注点有明确位置”的思路,但不照搬 Controller、Decorator、Module 和 DI 容器。
实际请求顺序是:
Next params hydration
→ Middleware
→ Guard
→ Interceptor enter
→ 声明的 Body / Query 解析
→ Pipe
→ Handler(request, context)
→ Interceptor exit
→ Response Serializer
Exception Filter 覆盖整个链路。
一个重要细节:Guard 在 JSON Body 解析之前执行。未登录或无权限的请求,不会先消费只能读取一次的 Request Body。
所有能力都可以按范围注入
除了内置响应插件,也可以自定义插件,把多个横切策略作为一个可复用单元注入:
import { type RoutePlugin } from 'next-route-kit'
class RequestTimingPlugin implements RoutePlugin {
readonly name = 'request-timing'
readonly runtime = 'both' as const
install() {
return {
interceptors: [
{
name: 'request-timing',
async intercept(context, next) {
const startedAt = Date.now()
try {
return await next()
} finally {
console.info({
requestId: context.locals.requestId,
pathname: context.meta.pathname,
durationMs: Date.now() - startedAt,
})
}
},
},
],
}
}
}
可注入的位置有三层:
createRoute({ plugins }) 所有派生 Route
→ route.extend({ plugins }) 某个业务边界
→ route({ use: [plugin] }) 单个接口
常见用途包括 Request ID、审计日志、权限、缓存、超时、异常映射、统一响应和可观测性。
完整的插件契约与执行顺序见:插件指南。
什么时候不该使用它?
不要为了统一而统一。
以下场景通常继续使用原生 Next.js Route Handler 更清晰:
- 流式响应;
- 文件上传与 Multipart;
- 签名校验的 Webhook;
- 重定向;
- 极其简单的一次性接口;
- 重度依赖某个协议或第三方 SDK 的边界接口。
它最适合的是:项目中存在大量 JSON API,且认证、权限、输入校验、异常映射、响应格式等横切逻辑已经明显重复。
开始使用
npm install next-route-kit
# 只有使用 Zod 时才安装;主包不依赖 Zod。
npm install @next-route-kit/zod zod
- GitHub:github.com/tech-zjf/ne…
- npm:www.npmjs.com/package/nex…
- 中文文档:github.com/tech-zjf/ne…
- API 响应与业务码:github.com/tech-zjf/ne…
Next.js 的文件路由和原生 Request / Response 都保留在原位;包只负责请求管道与可插拔策略。
如果你的项目里也有“一个 Route 文件 70% 都是鉴权、try/catch 和 NextResponse.json”的感觉,欢迎交流真实迁移案例、命名意见和使用反馈。