Next.js Route Handlers 完全指南:用 App Router 构建生产级 REST API

Route Handlers 是 Next.js App Router 构建 REST API 的核心能力。本文从文件约定、HTTP 方法到认证守卫、Webhook 签名验证、流式响应、缓存策略、Edge Runtime 全面覆盖,配合可直接用于生产环境的 TypeScript 代码,帮你快速上手。

引言:为什么你需要掌握 Route Handlers

如果你一直在跟我们这个系列的文章,到现在你应该已经知道怎么用 Server Actions 做数据变更、怎么用 fetch + use cache 搞定缓存策略、怎么用中间件做请求拦截,还有怎么搭建认证系统和用 Drizzle ORM 连接数据库。不过,有一个关键问题一直悬而未决——你的应用怎么向外部世界暴露 API?

Server Actions 确实好用,但它们只能被你自己的 React 组件调用。

想想看:你的移动端 App 需要拉取用户数据、Stripe 要给你发 Webhook 通知支付成功、某个合作伙伴的系统需要对接你的接口——这些场景下,Server Actions 完全帮不上忙。说实话,我刚开始接触 App Router 的时候也一度以为 Server Actions 能搞定一切,直到遇到第一个 Webhook 需求才意识到问题所在。

这就是 Route Handlers 登场的时候了。它是 Next.js App Router 中构建 REST API 的官方方案,基于 Web 标准的 Request/Response API,支持所有 HTTP 方法,能处理从简单的 JSON 接口到复杂的流式响应、Webhook 签名验证等各种场景。

接下来这篇文章,我会从零开始带你把 Route Handlers 的每个细节都过一遍,顺便给出可以直接搬到生产环境的代码。So, let's dive in.

Route Handlers 基础:文件约定与 HTTP 方法

文件约定

Route Handlers 定义在 app 目录下的 route.ts(或 route.js)文件中。跟 page.tsx 类似,文件路径直接决定了 API 的 URL 路径:

app/
├── api/
│   ├── users/
│   │   ├── route.ts          → GET/POST /api/users
│   │   └── [id]/
│   │       └── route.ts      → GET/PUT/DELETE /api/users/:id
│   ├── webhooks/
│   │   └── stripe/
│   │       └── route.ts      → POST /api/webhooks/stripe
│   └── health/
│       └── route.ts          → GET /api/health

有一条关键规则别忘了route.tspage.tsx 不能在同一个路由段中共存。也就是说,你在 app/api/users/ 下放了 route.ts,就不能同时再放 page.tsx。老实说这个限制偶尔会让人踩坑,但理解了之后其实挺合理的——一个路径要么是页面,要么是 API,不能两者兼得。

支持的 HTTP 方法

你只需要导出跟 HTTP 方法同名的异步函数,就能在同一个 route.ts 里处理多个方法:

// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'

// GET /api/users
export async function GET(request: NextRequest) {
  const users = await db.user.findMany()
  return NextResponse.json(users)
}

// POST /api/users
export async function POST(request: NextRequest) {
  const body = await request.json()
  const user = await db.user.create({ data: body })
  return NextResponse.json(user, { status: 201 })
}

支持的方法有:GETPOSTPUTPATCHDELETEHEADOPTIONS。如果客户端调用了你没定义的方法,Next.js 会自动返回 405 Method Not Allowed,不用你操心。

请求处理:解析你需要的一切数据

查询参数

NextRequest 提供了 nextUrl.searchParams,读取查询参数非常方便:

// GET /api/users?page=2&limit=10&role=admin
export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams
  const page = parseInt(searchParams.get('page') ?? '1')
  const limit = parseInt(searchParams.get('limit') ?? '20')
  const role = searchParams.get('role')

  const users = await db.user.findMany({
    where: role ? { role } : undefined,
    skip: (page - 1) * limit,
    take: limit,
  })

  return NextResponse.json({
    data: users,
    pagination: { page, limit }
  })
}

请求体

因为 Route Handlers 基于 Web 标准 API,所以它能处理各种格式的请求体。这一点我个人觉得设计得非常优雅:

export async function POST(request: NextRequest) {
  // JSON 格式
  const json = await request.json()

  // FormData 格式(文件上传场景)
  const formData = await request.formData()
  const file = formData.get('avatar') as File

  // 纯文本(Webhook 签名验证时经常要用)
  const text = await request.text()

  // 原始字节流
  const buffer = await request.arrayBuffer()
}

请求头与 Cookies

读取请求头和 Cookies 有两种方式,看你个人喜好:

import { cookies, headers } from 'next/headers'

export async function GET(request: NextRequest) {
  // 方式一:通过 NextRequest 实例
  const authHeader = request.headers.get('authorization')
  const token = request.cookies.get('session-token')?.value

  // 方式二:通过 next/headers(异步函数)
  const headersList = await headers()
  const cookieStore = await cookies()

  const contentType = headersList.get('content-type')
  const theme = cookieStore.get('theme')?.value

  return NextResponse.json({ theme })
}

动态路由与 TypeScript 类型安全

动态路由段

跟页面路由一样,Route Handlers 也支持动态路由段。不过有个小变化需要注意——params 在 Next.js 15+ 中变成了一个 Promise,所以得 await 一下:

// app/api/users/[id]/route.ts
export async function GET(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params

  const user = await db.user.findUnique({ where: { id } })

  if (!user) {
    return NextResponse.json(
      { error: '用户不存在' },
      { status: 404 }
    )
  }

  return NextResponse.json(user)
}

export async function DELETE(
  request: NextRequest,
  { params }: { params: Promise<{ id: string }> }
) {
  const { id } = await params
  await db.user.delete({ where: { id } })
  return new Response(null, { status: 204 })
}

用 RouteContext 实现强类型

Next.js 16 带来了一个很棒的改进——全局类型 RouteContext,它能自动从路由路径推导出参数类型,省去手动定义的麻烦:

// app/api/posts/[slug]/comments/[commentId]/route.ts
export async function GET(
  _req: NextRequest,
  ctx: RouteContext<'/posts/[slug]/comments/[commentId]'>
) {
  const { slug, commentId } = await ctx.params
  // slug: string, commentId: string — 自动推导,IntelliSense 完整支持
}

类型会在你运行 next devnext buildnext typegen 时自动生成。说实话,这个 DX 改进让我写路由参数的时候舒服多了。

响应构建:从 JSON 到流式传输

JSON 响应

最常见的场景就是返回 JSON 了,用 NextResponse.json() 或原生 Response.json() 都行:

// 设置自定义状态码和响应头
return NextResponse.json(
  { data: users, total: count },
  {
    status: 200,
    headers: {
      'X-Total-Count': String(count),
      'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300',
    },
  }
)

重定向

import { redirect } from 'next/navigation'

export async function GET(request: NextRequest) {
  const session = await getSession()
  if (!session) {
    redirect('/login')
  }
  // ...
}

流式响应

Route Handlers 原生支持 Web Streams API,这对于大数据量传输或实时数据推送简直是天然适配:

// app/api/stream/route.ts
export async function GET() {
  const encoder = new TextEncoder()

  const stream = new ReadableStream({
    async start(controller) {
      for (let i = 0; i < 10; i++) {
        controller.enqueue(
          encoder.encode(`data: ${JSON.stringify({ count: i })}\n\n`)
        )
        await new Promise(resolve => setTimeout(resolve, 1000))
      }
      controller.close()
    },
  })

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

这种 Server-Sent Events(SSE)模式在实时仪表盘、AI 聊天的流式输出等场景下特别好用。如果你做过类似 ChatGPT 那种逐字输出的效果,大概率会用到这个。

认证守卫:保护你的 API 端点

Route Handlers 本质上是公开的 HTTP 端点。这跟 Server Actions 不一样——任何人只要知道 URL 就能直接调用。所以认证不是可选的,是必须的。

基础认证模式

// lib/auth-guard.ts
import { auth } from '@/lib/auth'
import { NextRequest, NextResponse } from 'next/server'

type RouteHandler = (
  request: NextRequest,
  context: any
) => Promise<Response>

export function withAuth(handler: RouteHandler): RouteHandler {
  return async (request, context) => {
    const session = await auth()

    if (!session?.user) {
      return NextResponse.json(
        { error: '未授权访问' },
        { status: 401 }
      )
    }

    return handler(request, context)
  }
}
// app/api/profile/route.ts
import { withAuth } from '@/lib/auth-guard'

export const GET = withAuth(async (request) => {
  const session = await auth()
  const profile = await db.user.findUnique({
    where: { id: session!.user.id }
  })
  return NextResponse.json(profile)
})

基于角色的访问控制

// lib/role-guard.ts
export function withRole(role: string, handler: RouteHandler): RouteHandler {
  return withAuth(async (request, context) => {
    const session = await auth()

    if (session!.user.role !== role) {
      return NextResponse.json(
        { error: '权限不足' },
        { status: 403 }
      )
    }

    return handler(request, context)
  })
}

// app/api/admin/users/route.ts
export const GET = withRole('admin', async () => {
  const users = await db.user.findMany()
  return NextResponse.json(users)
})

这种高阶函数(HOF)模式让认证、日志、速率限制之类的横切逻辑可以在多个 Route Handler 之间轻松复用,避免到处复制粘贴。我个人在项目里基本都是这么组织的。

Webhook 处理:接收第三方回调

Webhook 大概是 Route Handlers 最经典的使用场景了。Server Actions 做不了这事儿,因为它们没有固定的、可预测的公开 URL。

Stripe Webhook 完整示例

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe'
import { NextRequest, NextResponse } from 'next/server'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)

export async function POST(request: NextRequest) {
  const body = await request.text()
  const signature = request.headers.get('stripe-signature')

  if (!signature) {
    return NextResponse.json(
      { error: '缺少签名' },
      { status: 400 }
    )
  }

  let event: Stripe.Event

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    )
  } catch (err) {
    console.error('Webhook 签名验证失败:', err)
    return NextResponse.json(
      { error: '签名无效' },
      { status: 400 }
    )
  }

  switch (event.type) {
    case 'checkout.session.completed':
      const session = event.data.object
      await db.order.update({
        where: { stripeSessionId: session.id },
        data: { status: 'paid' },
      })
      break
    case 'invoice.payment_failed':
      // 处理支付失败逻辑
      break
  }

  return NextResponse.json({ received: true })
}

划重点:Webhook 处理必须用 request.text() 读取原始请求体来做签名验证,千万别用 request.json()。为什么?因为 JSON 解析会改变原始字节顺序,导致签名校验必定失败。这个坑我见过不少人踩。

缓存策略:让你的 API 飞起来

从 Next.js 15 开始,Route Handlers 默认不缓存了——包括 GET 请求。这跟之前版本的行为不一样,如果你是从 14 升上来的话要特别留意。

静态缓存

对于那些不依赖请求数据的 GET 端点(比如站点配置),可以手动启用静态缓存:

// app/api/config/route.ts
export const dynamic = 'force-static'

export async function GET() {
  const config = await db.siteConfig.findFirst()
  return Response.json(config)
}

基于时间的重新验证

// app/api/products/route.ts
export const revalidate = 60 // 每 60 秒重新验证一次

export async function GET() {
  const products = await db.product.findMany({
    where: { isActive: true },
    orderBy: { createdAt: 'desc' },
  })
  return Response.json(products)
}

按需重新验证

配合 revalidateTag 可以实现精确的缓存失效,而不是一刀切地清掉所有缓存:

import { revalidateTag } from 'next/cache'

// GET 端点:使用标签标记缓存
export async function GET() {
  const data = await fetch('https://api.example.com/data', {
    next: { tags: ['external-data'] }
  })
  return Response.json(await data.json())
}

// POST 端点:触发重新验证
export async function POST() {
  revalidateTag('external-data')
  return Response.json({ revalidated: true })
}

Edge Runtime:全球低延迟响应

对于延迟敏感的端点,你可以让 Route Handler 跑在 Edge Runtime 上。只需要加一行导出声明:

// app/api/geo/route.ts
export const runtime = 'edge'

export async function GET(request: NextRequest) {
  const country = request.geo?.country ?? 'Unknown'
  const city = request.geo?.city ?? 'Unknown'

  return NextResponse.json({
    country,
    city,
    message: `你好,来自 ${city}, ${country} 的访客!`
  })
}

Edge Runtime 的限制:它不支持完整的 Node.js API(像 fsnet 这些都用不了),而且部分 npm 包可能也不兼容。所以它比较适合轻量级的场景——地理位置检测、A/B 测试分流、简单的数据转换之类的。涉及数据库操作或者重计算的端点还是老老实实用 Node.js Runtime 吧。

错误处理:构建健壮的 API

生产环境的 API 需要统一且可预测的错误响应格式。不然的话,前端同事(或者对接你 API 的第三方)大概率会来找你麻烦。

// lib/api-error.ts
export class ApiError extends Error {
  constructor(
    public statusCode: number,
    message: string,
    public code?: string
  ) {
    super(message)
  }
}

// lib/with-error-handler.ts
import { NextRequest, NextResponse } from 'next/server'

type RouteHandler = (req: NextRequest, ctx: any) => Promise<Response>

export function withErrorHandler(handler: RouteHandler): RouteHandler {
  return async (request, context) => {
    try {
      return await handler(request, context)
    } catch (error) {
      if (error instanceof ApiError) {
        return NextResponse.json(
          { error: error.message, code: error.code },
          { status: error.statusCode }
        )
      }

      console.error('未预期的错误:', error)
      return NextResponse.json(
        { error: '服务器内部错误' },
        { status: 500 }
      )
    }
  }
}

用起来很简单:

// app/api/users/[id]/route.ts
import { withErrorHandler } from '@/lib/with-error-handler'
import { ApiError } from '@/lib/api-error'

export const GET = withErrorHandler(async (request, { params }) => {
  const { id } = await params

  const user = await db.user.findUnique({ where: { id } })

  if (!user) {
    throw new ApiError(404, '用户不存在', 'USER_NOT_FOUND')
  }

  return NextResponse.json(user)
})

数据校验:用 Zod 守住入口

永远不要信任客户端发来的数据——这话说了无数遍,但还是有人不当回事。用 Zod 在 Route Handler 入口就做好校验,是最稳妥的做法:

import { z } from 'zod'

const createUserSchema = z.object({
  name: z.string().min(2).max(50),
  email: z.string().email(),
  role: z.enum(['user', 'admin']).default('user'),
})

export const POST = withErrorHandler(async (request: NextRequest) => {
  const body = await request.json()
  const result = createUserSchema.safeParse(body)

  if (!result.success) {
    return NextResponse.json(
      {
        error: '请求数据校验失败',
        details: result.error.flatten().fieldErrors,
      },
      { status: 400 }
    )
  }

  const user = await db.user.create({ data: result.data })
  return NextResponse.json(user, { status: 201 })
})

CORS 配置:开放跨域访问

如果你的 API 需要被其他域名的前端调用(比如你做的是微服务或者独立 API),就得配置 CORS 头:

// app/api/public/data/route.ts
const corsHeaders = {
  'Access-Control-Allow-Origin': 'https://your-frontend.com',
  'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
  'Access-Control-Allow-Headers': 'Content-Type, Authorization',
}

export async function OPTIONS() {
  return new Response(null, { headers: corsHeaders })
}

export async function GET() {
  const data = await db.publicData.findMany()
  return NextResponse.json(data, { headers: corsHeaders })
}

如果你有好几个端点都需要 CORS,建议把这段逻辑也抽成一个高阶函数,跟认证守卫一样复用就好了。

Route Handlers vs Server Actions:何时用什么

这可能是很多刚接触 App Router 的开发者最纠结的问题了。别慌,下面这张表能帮你快速做决定:

场景推荐方案原因
表单提交、数据变更(CRUD)Server Actions类型安全,与 React 深度集成
移动端 App 调用Route Handlers需要公开、稳定的 URL
第三方 WebhookRoute Handlers需要可预测的端点地址
文件上传Route Handlers更精细的流控制
实时数据流(SSE)Route Handlers需要流式 Response
公开 RESTful APIRoute Handlers标准 HTTP 语义
需要缓存的 GET 数据Route HandlersHTTP 缓存控制更灵活
乐观更新(Optimistic UI)Server Actions与 useOptimistic 配合

最佳实践:把核心业务逻辑抽到数据访问层(Data Access Layer)里,让 Server Actions 和 Route Handlers 都调用同一套底层函数。这样不仅逻辑保持一致,日后想换调用方式也不费劲。

生产级项目结构

好了,把上面讲的所有模式组合到一起,一个真实的生产级 Next.js API 项目大概长这样:

app/
├── api/
│   ├── v1/
│   │   ├── users/
│   │   │   ├── route.ts          → 用户列表 & 创建
│   │   │   └── [id]/
│   │   │       └── route.ts      → 单个用户 CRUD
│   │   ├── products/
│   │   │   ├── route.ts
│   │   │   └── [slug]/
│   │   │       └── route.ts
│   │   └── orders/
│   │       └── route.ts
│   ├── webhooks/
│   │   ├── stripe/
│   │   │   └── route.ts
│   │   └── github/
│   │       └── route.ts
│   └── health/
│       └── route.ts
lib/
├── api/
│   ├── with-auth.ts              → 认证高阶函数
│   ├── with-error-handler.ts     → 错误处理包装器
│   ├── with-rate-limit.ts        → 速率限制
│   └── api-error.ts              → 自定义错误类
├── dal/                          → 数据访问层
│   ├── users.ts
│   ├── products.ts
│   └── orders.ts
└── validations/                  → Zod Schema
    ├── user.ts
    ├── product.ts
    └── order.ts

API 版本化(/api/v1/)不是每个项目都必须的。但如果你的 API 会被外部消费者使用,加上版本号能为将来的破坏性变更留一条退路。

Next.js 16 的重要变化

如果你的项目已经升到 Next.js 16(或者正打算升),有几个跟 Route Handlers 相关的变化值得关注:

  • middleware.ts 更名为 proxy.ts:导出函数也从 middleware 改为 proxy。如果你的中间件里有认证检查逻辑,记得更新函数签名。
  • Turbopack 默认启用:新项目开箱就用 Turbopack 打包了,开发和生产构建速度有明显提升。
  • React Compiler 内置支持:自动做组件记忆化,减少不必要的重新渲染。
  • RouteContext 全局类型:就是前面讲到的那个强类型路由参数支持,非常实用。
  • Cache Components:结合 PPR 和 use cache 的新编程模型,可以让 Route Handler 中的部分数据实现静态预渲染。

常见问题解答(FAQ)

Route Handlers 和 Pages Router 的 API Routes 有什么区别?

最大的区别在底层 API。Pages Router 的 API Routes 基于 Node.js 的 req/res 对象(用过 Express 的话会很熟悉),而 App Router 的 Route Handlers 基于 Web 标准的 Request/Response API。除此之外,Route Handlers 还支持 Edge Runtime、流式响应,并且能跟 App Router 的缓存体系无缝配合。如果你在用 Next.js 14 以上版本,官方建议优先使用 Route Handlers。

Route Handlers 默认会被缓存吗?

从 Next.js 15 开始,不会。之前版本的 GET 请求默认缓存,但这让很多开发者摸不着头脑("我明明改了数据为啥没更新?"),所以 15 版本改成了默认不缓存。如果你确实需要缓存,可以通过 export const dynamic = 'force-static' 或者配置 revalidate 时间来显式开启。

Route Handlers 里可以使用 Server Actions 吗?

技术上可以在 Route Handler 里调一个 Server Action 函数,但真心不推荐。更好的做法是把共享逻辑放到数据访问层(DAL),让 Route Handler 和 Server Action 各自去调同一个底层函数。这样职责更清晰,测试也更方便。

如何在本地测试 Webhook?

ngrok 是最简单的办法——运行 ngrok http 3000,它会给你生成一个公开的隧道 URL(比如 https://abc123.ngrok.io/api/webhooks/stripe),然后把这个 URL 填到第三方服务的 Webhook 配置里就行了。

Route Handlers 能在 Edge Runtime 上运行吗?有什么限制?

能,在文件里加上 export const runtime = 'edge' 就可以。Edge Runtime 跑在离用户更近的节点上,延迟更低,但不支持完整的 Node.js API(比如 fscrypto 的部分方法、原生数据库驱动等等都不能用)。地理位置检测、轻量数据处理、A/B 测试这类场景很合适;但需要直连数据库或者做复杂计算的端点,还是用默认的 Node.js Runtime 比较靠谱。

关于作者 Editorial Team

Our team of expert writers and editors.