← 返回AI变现
🌐 其他

当 AI 把 Next.js Route 越写越快:我为什么做了 next-route-kit

来源:掘金 · 发布于 2026-08-20 10:54:27
当 AI 把 Next.js
从真实 Next.js 全栈项目出发,抽离鉴权、校验、异常与统一响应等重复逻辑,开源 next-route-kit,让 Route Handler 保持原生、可组合、易维护。

当 AI 把 Next.js Route 越写越快:我为什么做了 next-route-kit

tech_zjf 2026-08-20 0 阅读8分钟

一个面向 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 项目也有类似问题,希望它能解决其中一部分。

next-route-kit 请求链路

一个真实且常见的 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

Next.js 的文件路由和原生 Request / Response 都保留在原位;包只负责请求管道与可插拔策略。

如果你的项目里也有“一个 Route 文件 70% 都是鉴权、try/catch 和 NextResponse.json”的感觉,欢迎交流真实迁移案例、命名意见和使用反馈。