Route Handlers:在 Next.js 中编写 API

1. 概述

在 Next.js 的 App Router 中,你不需要单独搭建 Express 服务器来拥有 API。Route Handlers 允许你在 app 文件夹中直接编写 HTTP endpoint,使用标准的 Web API Request 和 Response。

本文将构建一个小的订单管理 API(Order)。你将学习如何定义 HTTP method、读取查询字符串和请求体、处理动态段、处理 headers/cookies、控制缓存,以及使用 Vitest 编写测试。示例使用 Next.js 15+ 和 TypeScript。

2. 第一个 Route Handler

Route Handler 是位于 app 中的名为 route.ts(或 route.js)的文件。文件夹路径决定了 URL。每个 HTTP method 是一个用大写字母命名的 export 函数。

首先,我们需要一个简单的内存数据“仓库”:

// lib/orders.ts
export type Order = {
  id: string;
  customer: string;
  total: number;
};

const orders: Order[] = [
  { id: "1", customer: "An", total: 250_000 },
  { id: "2", customer: "Bình", total: 1_200_000 },
];

export const orderStore = {
  findAll: () => [...orders],
  findById: (id: string) => orders.find((o) => o.id === id),
  create: (data: Omit<Order, "id">): Order => {
    const order = { id: String(orders.length + 1), ...data };
    orders.push(order);
    return order;
  },
  remove: (id: string) => {
    const index = orders.findIndex((o) => o.id === id);
    if (index === -1) return false;
    orders.splice(index, 1);
    return true;
  },
};

现在创建 GET /api/orders endpoint:

// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";

export async function GET() {
  return Response.json(orderStore.findAll());
}

说明:

  • 文件位于 app/api/orders/route.ts,所以 URL 是 /api/orders。api 前缀只是约定。Next.js 不强制要求。
  • export async function GET() 处理 GET 请求。Next.js 会自动为你未导出的 method 返回 405 Method Not Allowed。
  • Response.json(...) 是标准的 Web API。它序列化数据并设置 Content-Type: application/json 头。

运行 npm run dev,然后尝试调用:

curl http://localhost:3000/api/orders
[{"id":"1","customer":"An","total":250000},{"id":"2","customer":"Bình","total":1200000}]

**注意:**在同一个目录中,你不能同时拥有 route.ts 和 page.tsx。例如 app/orders/page.tsx 和 app/orders/route.ts 会产生冲突。这就是为什么人们通常将 API 放在 app/api 中。

3. 读取查询字符串和请求体

3.1. 查询字符串

handler 参数是一个标准的 Request。要读取查询字符串,你需要从 request.url 创建一个 URL:

// app/api/orders/route.ts
import { orderStore } from "@/lib/orders";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const minTotal = Number(searchParams.get("minTotal") ?? 0);

  const result = orderStore.findAll().filter((o) => o.total >= minTotal);
  return Response.json(result);
}

调用 /api/orders?minTotal=500000 将只返回 Bình 的订单:

[{"id":"2","customer":"Bình","total":1200000}]

如果你想要,你可以将参数声明为 NextRequest(来自 next/server)并使用 request.nextUrl.searchParams。这里我使用标准 Request 是因为它在测试中更容易创建。

3.2. JSON 请求体和验证

不要相信客户端发送的数据。我们使用 zod 来验证请求体。安装库:

npm install zod

在同一个文件中添加 POST handler:

// app/api/orders/route.ts (补充)
import { z } from "zod";

const CreateOrderSchema = z.object({
  customer: z.string().min(1),
  total: z.number().positive(),
});

export async function POST(request: Request) {
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return Response.json({ error: "Body 不是有效的 JSON" }, { status: 400 });
  }

  const parsed = CreateOrderSchema.safeParse(body);
  if (!parsed.success) {
    return Response.json(
      { error: "数据无效", issues: parsed.error.issues },
      { status: 422 },
    );
  }

  const order = orderStore.create(parsed.data);
  return Response.json(order, {
    status: 201,
    headers: { Location: `/api/orders/${order.id}` },
  });
}

说明:

  • 如果 body 为空或语法错误,request.json() 会抛出异常。因此我们将其包装在 try/catch 中并返回 400。
  • safeParse 不会抛出异常。它返回 { success, data } 或 { success, error }。类型错误的数据会收到 422。
  • Response.json 的第二个参数允许你设置 status 和 headers。这里我们返回 201 Created 和 Location 头。
curl -i -X POST http://localhost:3000/api/orders \
  -H "Content-Type: application/json" \
  -d '{"customer":"Chi","total":99000}'
HTTP/1.1 201 Created
location: /api/orders/3
content-type: application/json

{"id":"3","customer":"Chi","total":99000}

对于 HTML 表单,你使用 await request.formData()。对于原始数据,你使用 await request.text()。请注意,body 只能读取一次。

4. 动态段

要有 GET /api/orders/:id,你需要创建一个用方括号括起来的名称的文件夹:

// app/api/orders/[id]/route.ts
import { orderStore } from "@/lib/orders";

type Context = { params: Promise<{ id: string }> };

export async function GET(_request: Request, { params }: Context) {
  const { id } = await params;
  const order = orderStore.findById(id);

  if (!order) {
    return Response.json({ error: `找不到订单 ${id}` }, { status: 404 });
  }
  return Response.json(order);
}

export async function DELETE(_request: Request, { params }: Context) {
  const { id } = await params;
  const removed = orderStore.remove(id);
  return new Response(null, { status: removed ? 204 : 404 });
}

说明:

  • 第二个参数包含 params。从 Next.js 15 开始,params 是一个 Promise,所以你必须 await 它。
  • DELETE 返回 204 No Content 且 body 为 null。你不应该对此情况使用 Response.json,因为 204 不应该有 body。
  • params 中的值总是 string。如果 ID 是数字,你需要自己转换和验证。

你也可以使用 catch-all [...slug](接收 string[])或 optional catch-all [[...slug]]。

**注意:**在 Next.js 15+ 上,不使用 await 直接写 params.id 的旧代码会遇到警告或错误。从 Next.js 14 升级时,这是最常见的破坏点。

5. Headers、cookies 和重定向

你可以直接从 request.headers 读取 headers。此外,Next.js 从 next/headers 提供 cookies() 和 headers() 函数。两者都是 async 函数。

// app/api/me/route.ts
import { cookies, headers } from "next/headers";

export async function GET() {
  const cookieStore = await cookies();
  const headerList = await headers();

  const session = cookieStore.get("session")?.value;
  if (!session) {
    return Response.json({ error: "未登录" }, { status: 401 });
  }

  return Response.json({
    session,
    userAgent: headerList.get("user-agent"),
  });
}

export async function DELETE() {
  const cookieStore = await cookies();
  cookieStore.delete("session");
  return new Response(null, { status: 204 });
}
  • cookieStore.get(...) 用于读取 cookie。set 和 delete 只在 Route Handler 或 Server Action 中有效,不能在 Server Component 中使用。
  • 使用 cookies() 或 headers() 将使 route 成为 dynamic,这意味着它在每个请求时都会被呈现。

如果需要重定向,你可以使用 Response.redirect(new URL("/login", request.url), 307) 或来自 next/navigation 的 redirect() 函数。

对于 CORS,你需要自己设置 header 并为 preflight 导出 OPTIONS:

// app/api/public/route.ts
const corsHeaders = {
  "Access-Control-Allow-Origin": "https://shop.example.com",
  "Access-Control-Allow-Methods": "GET, OPTIONS",
};

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

export async function GET() {
  return Response.json({ status: "ok" }, { headers: corsHeaders });
}

6. 缓存

这是最容易混淆的部分。从 Next.js 15 开始,GET handler 默认不再被缓存。每个请求都会重新运行该函数。POST、PUT、DELETE 等其他 method 永远不会被缓存。

如果数据变化不频繁,并且你希望在构建时预先生成为静态文件,请通过 route segment config 选择加入:

// app/api/categories/route.ts
export const dynamic = "force-static";
export const revalidate = 3600; // 最多每小时重新验证一次

export async function GET() {
  const categories = ["Điện tử", "Sách", "Thời trang"];
  return Response.json({ categories, generatedAt: new Date().toISOString() });
}
  • dynamic = "force-static" 强制在构建时呈现 route。之后,结果作为静态文件提供。
  • revalidate = 3600 允许 Next.js 最多每小时重新生成响应(ISR)。
  • 在运行 next dev 时,route 仍然在每个请求时运行。要看到 generatedAt 保持不变,你需要运行 next build && next start。

**注意:**如果 force-static route 读取 cookies()、headers() 或查询字符串,这些值在构建时会为空。不要对依赖用户的 endpoint 使用静态。

7. 使用 Vitest 测试 Route Handler

Route Handler 只是一个 async 函数,接收 Request 并返回 Response,所以你可以直接调用它,而不需要启动服务器。安装 Vitest:

npm install -D vitest vite-tsconfig-paths
// vitest.config.ts
import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";

export default defineConfig({
  plugins: [tsconfigPaths()],
  test: { environment: "node" },
});

插件 vite-tsconfig-paths 帮助别名 @/lib/... 在测试中工作。现在编写测试:

// app/api/orders/route.test.ts
import { describe, it, expect } from "vitest";
import { GET, POST } from "./route";

function jsonRequest(body: unknown) {
  return new Request("http://localhost/api/orders", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(body),
  });
}

describe("/api/orders", () => {
  it("按 minTotal 过滤订单", async () => {
    const res = await GET(new Request("http://localhost/api/orders?minTotal=500000"));
    const data = await res.json();

    expect(res.status).toBe(200);
    expect(data.every((o: { total: number }) => o.total >= 500_000)).toBe(true);
  });

  it("创建有效订单并返回 201", async () => {
    const res = await POST(jsonRequest({ customer: "Dũng", total: 150_000 }));

    expect(res.status).toBe(201);
    expect(res.headers.get("Location")).toMatch(/^\/api\/orders\/\d+$/);
    expect(await res.json()).toMatchObject({ customer: "Dũng", total: 150_000 });
  });

  it("拒绝负数 total 并返回 422", async () => {
    const res = await POST(jsonRequest({ customer: "Dũng", total: -1 }));
    expect(res.status).toBe(422);
  });
});
npx vitest run
 ✓ app/api/orders/route.test.ts (3 tests)
 Test Files  1 passed (1)
      Tests  3 passed (3)

如果 handler 使用来自 next/headers 的 cookies() 或 headers(),直接调用会抛出异常,因为缺少 Next.js 的请求上下文。在这种情况下,你有两个选择:使用 vi.mock("next/headers", ...) 模拟模块,或者从 request.headers 读取值,使代码更容易测试。

8. 常见错误和何时不应该使用

一些常见错误:

  • **从 Server Component 获取 Route Handler。**在 Server Component 中调用 fetch("/api/orders") 会产生多余的 HTTP 往返。构建时,这个调用甚至会失败,因为服务器还没有运行。相反,直接调用 orderStore.findAll() 或数据查询层。
  • **为内部表单的 mutation 使用 Route Handler。**如果只有应用本身的 UI 调用它,Server Actions 更简洁。它们具有端到端的类型安全,并且与 revalidatePath 内置集成。
  • 在模块变量中保存状态,如上面的 orderStore,在部署无服务器时。每个实例都有自己的内存,可能随时被重新启动。本文中的示例仅用于说明。实际上你需要使用数据库。
  • **忘记 body 只能读一次。**如果需要读两次,使用 request.clone()。
  • **返回无结构的错误。**未捕获的异常会变成 500 并显示 HTML 错误页面。始终返回具有明确 status 的 JSON。

Route Handler 适合于以下情况:

  • 一个外部客户端的 endpoint,如移动应用或合作伙伴。
  • 一个接收数据的 webhook,例如来自 Stripe 或 GitHub。
  • 返回文件、RSS 或流数据的 endpoint。
  • 客户端代码通过 fetch 调用的 endpoint。

9. 总结

Route Handlers 将每个 route.ts 文件变成一个 HTTP endpoint,使用标准的 Web Request/Response。在本文中,你学到了:

  • 定义 GET、POST、DELETE method。
  • 读取查询字符串和请求体,使用 zod 验证请求体。
  • 使用 Promise 形式的 params 处理动态段。
  • 处理 headers、cookies 和 CORS。
  • 在需要时启用静态缓存。
  • 使用 Vitest 直接测试 handler,无需服务器。

要记住的主要原则是:为公开 API 或 webhook 使用 Route Handler。对于内部数据,直接在 Server Component 中查询。对于应用本身的表单 mutation,使用 Server Actions。

nextjsroute-handlersapitypescriptapp-router