Route Handlers:构建类型安全的 API 端点
第16章:Route Handlers:构建类型安全的 API 端点
Route Handlers 基于 Web 标准 API(Request/Response),是 Next.js 全栈能力的对外接口——处理移动客户端、Webhooks、流式 AI 响应等场景。
本章核心问题:Route Handlers 与 Server Actions 如何选择?如何构建类型安全、带速率限制的 API?
读完本章你将理解:
- HTTP 方法导出与 NextRequest/NextResponse 的使用
- Zod 验证、CORS 配置、速率限制的生产级实现
- Webhook 签名验证与流式响应(ReadableStream)
Level 1 · 你需要知道的(1-3年经验)
Route Handlers 是什么,为什么需要它
App Router 引入了 Route Handlers 来替代 Pages Router 中的 API Routes。两者在功能上相似,但 Route Handlers 建立在标准 Web API(Request/Response)之上,而不是 Node.js 特有的 req/res 对象。这个设计决策意义深远:同样的代码可以在 Node.js、Edge Runtime、甚至 Deno 上运行。
Route Handlers 存放在 app/ 目录下,文件名必须是 route.ts(或 route.js)。路径规则与页面路由相同,但同一目录下不能同时存在 page.tsx 和 route.ts——一个目录只能是页面或 API 端点,不能两者兼具。
app/
api/
users/
route.ts ← GET /api/users, POST /api/users
[id]/
route.ts ← GET /api/users/[id], PUT /api/users/[id]
posts/
route.ts
HTTP 方法导出与 NextRequest/NextResponse
Route Handlers 通过导出以 HTTP 方法命名的函数来处理请求:
// app/api/users/route.ts
import { NextRequest, NextResponse } from 'next/server'
export async function GET(request: NextRequest) {
return NextResponse.json({ users: [] })
}
export async function POST(request: NextRequest) {
const body = await request.json()
// 处理创建逻辑
return NextResponse.json({ id: '123' }, { status: 201 })
}
支持的方法:GET、POST、PUT、PATCH、DELETE、HEAD、OPTIONS。不支持的方法会自动返回 405 Method Not Allowed。
NextRequest 是 Web 标准 Request 的扩展,增加了 nextUrl(解析后的 URL 对象)、cookies(类型安全的 cookie 操作)和 geo(地理位置)等属性。
读取请求数据
请求体
export async function POST(request: NextRequest) {
// JSON 请求体
const body = await request.json()
// 表单数据
const formData = await request.formData()
const name = formData.get('name') as string
const file = formData.get('file') as File
// 原始文本
const text = await request.text()
// ArrayBuffer(二进制)
const buffer = await request.arrayBuffer()
}
查询参数与路径参数
// app/api/users/[id]/route.ts
interface Context {
params: Promise<{ id: string }>
}
export async function GET(
request: NextRequest,
context: Context
) {
const { id } = await context.params
// 查询参数:/api/users/123?include=posts&limit=10
const { searchParams } = request.nextUrl
const include = searchParams.get('include')
const limit = parseInt(searchParams.get('limit') ?? '20', 10)
return NextResponse.json({ id, include, limit })
}
注意在 Next.js 15 中,params 是一个 Promise,必须 await。这是从 Next.js 14 到 15 的重要变更,忘记 await 会导致运行时错误。
请求头
export async function GET(request: NextRequest) {
const authHeader = request.headers.get('authorization')
const contentType = request.headers.get('content-type')
const userAgent = request.headers.get('user-agent')
if (!authHeader?.startsWith('Bearer ')) {
return NextResponse.json(
{ error: 'Unauthorized' },
{ status: 401 }
)
}
}
Level 2 · 它是怎么运行的(3-5年经验)
使用 Zod 构建类型安全的 API
在生产环境中,绝不能信任任何来自客户端的数据。Zod 提供了运行时验证,并能自动推断 TypeScript 类型,是 Route Handlers 的最佳搭档:
// app/api/posts/route.ts
import { z } from 'zod'
import { NextRequest, NextResponse } from 'next/server'
import { db } from '@/lib/db'
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(10),
tags: z.array(z.string()).max(10).optional().default([]),
publishedAt: z.string().datetime().optional(),
})
type CreatePostInput = z.infer<typeof CreatePostSchema>
export async function POST(request: NextRequest) {
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json(
{ error: 'Invalid JSON' },
{ status: 400 }
)
}
const result = CreatePostSchema.safeParse(body)
if (!result.success) {
return NextResponse.json(
{
error: 'Validation failed',
details: result.error.flatten().fieldErrors,
},
{ status: 422 }
)
}
const data: CreatePostInput = result.data
const post = await db.post.create({
data: {
title: data.title,
content: data.content,
tags: data.tags,
publishedAt: data.publishedAt ? new Date(data.publishedAt) : null,
},
})
return NextResponse.json(post, { status: 201 })
}
safeParse 不抛出异常,而是返回 { success: true, data } 或 { success: false, error } 对象,让错误处理更加明确。
设置响应头与状态码
export async function GET(request: NextRequest) {
const data = await fetchData()
return NextResponse.json(data, {
status: 200,
headers: {
'Cache-Control': 'public, max-age=60, stale-while-revalidate=300',
'X-Custom-Header': 'my-value',
},
})
}
或者使用标准 Response 构造函数(Next.js 也支持):
export async function GET() {
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
})
}
流式响应:ReadableStream
对于大型数据集或 AI 生成内容,流式响应能显著改善用户体验——用户看到第一个字节的时间大幅缩短,而不是等待整个响应完成:
// app/api/stream/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
const items = ['Hello', ' ', 'World', '!']
for (const item of items) {
controller.enqueue(encoder.encode(item))
// 模拟延迟(实际场景是 AI 模型的 token 输出)
await new Promise(resolve => setTimeout(resolve, 100))
}
controller.close()
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/plain; charset=utf-8',
'Transfer-Encoding': 'chunked',
},
})
}
对于 Server-Sent Events(SSE)格式,适合实时推送场景:
// app/api/events/route.ts
export async function GET() {
const encoder = new TextEncoder()
const stream = new ReadableStream({
async start(controller) {
function send(data: object) {
const formatted = `data: ${JSON.stringify(data)}\n\n`
controller.enqueue(encoder.encode(formatted))
}
// 发送初始事件
send({ type: 'connected', timestamp: Date.now() })
// 每秒发送一次心跳
let count = 0
const interval = setInterval(() => {
send({ type: 'heartbeat', count: ++count })
if (count >= 10) {
clearInterval(interval)
controller.close()
}
}, 1000)
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
})
}
Route Handlers vs Server Actions:如何选择
这是 App Router 中最常见的架构决策问题:
| 场景 | Route Handlers | Server Actions |
|---|---|---|
| 外部客户端调用(移动 App、第三方) | ✓ | ✗ |
| Webhooks(Stripe、GitHub 等) | ✓ | ✗ |
| 文件下载 / 二进制响应 | ✓ | ✗ |
| 表单提交(来自 Next.js 前端) | ✗ | ✓ |
| 数据变更(CRUD) | 可以 | 更简单 |
| 流式 AI 响应 | ✓ | ✓(useActionState) |
选择 Route Handlers 的核心标准:调用方不是你的 Next.js 前端代码。如果是移动 App、第三方服务、或者需要特定 HTTP 语义(状态码、Content-Type、流式),用 Route Handlers。
选择 Server Actions 的核心标准:从 React 组件触发的数据变更,特别是表单提交。Server Actions 自动处理 CSRF 保护,能与 React 的并发特性(useFormStatus、useOptimistic)无缝集成。
Level 3 · 规范怎么定义的(资深)
CORS 配置
如果你的 Route Handler 需要被跨域访问(如提供公共 API),需要配置 CORS 头:
// lib/cors.ts
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
const ALLOWED_ORIGINS = [
'https://app.example.com',
'https://admin.example.com',
]
export function corsHeaders(origin: string | null) {
const allowedOrigin = ALLOWED_ORIGINS.find(o => o === origin)
return {
'Access-Control-Allow-Origin': allowedOrigin ?? 'null',
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
'Access-Control-Max-Age': '86400',
}
}
// app/api/public/route.ts
import { corsHeaders } from '@/lib/cors'
export async function OPTIONS(request: NextRequest) {
const origin = request.headers.get('origin')
return new Response(null, {
status: 204,
headers: corsHeaders(origin),
})
}
export async function GET(request: NextRequest) {
const origin = request.headers.get('origin')
const data = await getPublicData()
return NextResponse.json(data, {
headers: corsHeaders(origin),
})
}
预检请求(OPTIONS)必须单独处理,并返回 204 No Content。
速率限制
Route Handlers 暴露在互联网上,必须防止滥用。使用 Upstash Redis 实现无状态的分布式速率限制:
// lib/rateLimit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'), // 10 秒内最多 10 次请求
})
// app/api/search/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { ratelimit } from '@/lib/rateLimit'
export async function GET(request: NextRequest) {
const ip = request.ip ?? request.headers.get('x-forwarded-for') ?? '127.0.0.1'
const { success, limit, remaining, reset } = await ratelimit.limit(ip)
if (!success) {
return NextResponse.json(
{ error: 'Too many requests' },
{
status: 429,
headers: {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
'X-RateLimit-Reset': String(reset),
'Retry-After': String(Math.round((reset - Date.now()) / 1000)),
},
}
)
}
const query = request.nextUrl.searchParams.get('q')
const results = await search(query)
return NextResponse.json(results, {
headers: {
'X-RateLimit-Limit': String(limit),
'X-RateLimit-Remaining': String(remaining),
},
})
}
Upstash Redis 是 Serverless 友好的 KV 存储,支持 HTTP API,在 Edge Runtime 和 Serverless Functions 中均可使用。
Webhook 处理:签名验证
Webhook 端点必须验证请求来源,防止伪造请求:
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import Stripe from 'stripe'
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!)
export async function POST(request: NextRequest) {
const body = await request.text() // 必须读取原始文本,不能 JSON.parse
const signature = request.headers.get('stripe-signature')
if (!signature) {
return NextResponse.json({ error: 'Missing signature' }, { status: 400 })
}
let event: Stripe.Event
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
)
} catch (err) {
return NextResponse.json(
{ error: 'Invalid signature' },
{ status: 400 }
)
}
// 异步处理,立即返回 200 避免 Stripe 重试
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object)
break
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object)
break
}
return NextResponse.json({ received: true })
}
关键点:读取 Stripe 请求体必须用 request.text(),不能用 request.json()。Stripe 的签名是基于原始字节计算的,JSON.parse 再 JSON.stringify 可能改变空白字符,导致签名验证失败。
文件下载
Route Handlers 非常适合生成动态文件内容:
// app/api/export/csv/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { getUserData } from '@/lib/db'
export async function GET(request: NextRequest) {
const userId = request.headers.get('x-user-id')
const data = await getUserData(userId!)
// 生成 CSV 内容
const rows = [
['ID', 'Name', 'Email', 'Created At'],
...data.map(row => [row.id, row.name, row.email, row.createdAt.toISOString()]),
]
const csv = rows.map(row => row.join(',')).join('\n')
return new Response(csv, {
headers: {
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="export.csv"',
},
})
}
Level 4 · 边界与陷阱(所有人)
陷阱1:同一目录下不能同时存在 page.tsx 和 route.ts——一个目录只能是页面或 API 端点。
陷阱2:读取 Stripe Webhook 请求体必须用 request.text(),不能用 request.json()——JSON.parse 再 stringify 可能改变空白字符,导致签名验证失败。
陷阱3:选择标准:调用方是外部世界(移动 App、第三方服务)用 Route Handlers;调用方是内部 React 组件用 Server Actions。
小结
Route Handlers 是 Next.js 全栈能力的对外接口:它们处理来自外部世界的 HTTP 请求,无论是移动客户端、第三方 Webhooks 还是浏览器直接发起的请求。基于 Web 标准 API 的设计让代码更易测试和移植。掌握 Zod 验证、正确的 CORS 配置和速率限制是生产级 Route Handler 的三要素。面对"该用 Route Handler 还是 Server Action"的抉择时,思考调用方是谁:外部世界用 Route Handlers,内部 React 组件用 Server Actions。