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-staticroute 读取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、DELETEmethod。 - 读取查询字符串和请求体,使用
zod验证请求体。 - 使用 Promise 形式的
params处理动态段。 - 处理 headers、cookies 和 CORS。
- 在需要时启用静态缓存。
- 使用 Vitest 直接测试 handler,无需服务器。
要记住的主要原则是:为公开 API 或 webhook 使用 Route Handler。对于内部数据,直接在 Server Component 中查询。对于应用本身的表单 mutation,使用 Server Actions。