第 16 章

Route Handlers:构建类型安全的 API 端点

第16章:Route Handlers:构建类型安全的 API 端点

Route Handlers 基于 Web 标准 API(Request/Response),是 Next.js 全栈能力的对外接口——处理移动客户端、Webhooks、流式 AI 响应等场景。

本章核心问题:Route Handlers 与 Server Actions 如何选择?如何构建类型安全、带速率限制的 API?

读完本章你将理解


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.tsxroute.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 })
}

支持的方法:GETPOSTPUTPATCHDELETEHEADOPTIONS。不支持的方法会自动返回 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 的并发特性(useFormStatususeOptimistic)无缝集成。

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.parseJSON.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。

本章评分
4.5  / 5  (15 评分)

💬 留言讨论