引言:为什么你需要掌握 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.ts 和 page.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 })
}
支持的方法有:GET、POST、PUT、PATCH、DELETE、HEAD、OPTIONS。如果客户端调用了你没定义的方法,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 dev、next build 或 next 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(像 fs、net 这些都用不了),而且部分 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 |
| 第三方 Webhook | Route Handlers | 需要可预测的端点地址 |
| 文件上传 | Route Handlers | 更精细的流控制 |
| 实时数据流(SSE) | Route Handlers | 需要流式 Response |
| 公开 RESTful API | Route Handlers | 标准 HTTP 语义 |
| 需要缓存的 GET 数据 | Route Handlers | HTTP 缓存控制更灵活 |
| 乐观更新(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(比如 fs、crypto 的部分方法、原生数据库驱动等等都不能用)。地理位置检测、轻量数据处理、A/B 测试这类场景很合适;但需要直连数据库或者做复杂计算的端点,还是用默认的 Node.js Runtime 比较靠谱。