← 返回目录

第六章:API 路由

Route Handlers、中间件与 RESTful API

1. Route Handlers 基础

App Router 中,HTTP API 通过 route.ts(或 route.js)定义,文件放在 app/api/ 目录下。每个 route 文件可导出与 HTTP 动词同名的异步函数,称为 Route Handlers(取代 Pages Router 中的 pages/api 路由)。

GET:返回 JSON

// app/api/hello/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  return NextResponse.json({ message: "Hello from Route Handler", ok: true });
}

POST:读取请求体

// app/api/echo/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(request: NextRequest) {
  const body = await request.json();
  return NextResponse.json({ received: body }, { status: 201 });
}

NextRequest 继承自 Request,额外提供 nextUrlcookies 等。NextResponse 可构造 JSON、重定向、改写 headers/cookies,与中间件配合良好。

2. 动态 API 路由

与页面路由类似,使用文件夹名 [id] 即可定义动态段。例如 app/api/users/[id]/route.ts 对应路径 /api/users/:id

Next.js 15 中,Route Handler 的第二个参数里的 paramsPromise,需 await 后再读取动态值:

// app/api/users/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";

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

// 内存示例(生产环境请换数据库)
const users = new Map<string, { id: string; name: string }>([
  ["1", { id: "1", name: "Ada" }],
]);

export async function GET(_request: NextRequest, context: Ctx) {
  const { id } = await context.params;
  const user = users.get(id);
  if (!user) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }
  return NextResponse.json(user);
}

export async function PUT(request: NextRequest, context: Ctx) {
  const { id } = await context.params;
  const body = await request.json();
  const name = typeof body.name === "string" ? body.name : undefined;
  if (!name) {
    return NextResponse.json({ error: "name required" }, { status: 400 });
  }
  const updated = { id, name };
  users.set(id, updated);
  return NextResponse.json(updated);
}

export async function DELETE(_request: NextRequest, context: Ctx) {
  const { id } = await context.params;
  const existed = users.delete(id);
  if (!existed) {
    return NextResponse.json({ error: "Not found" }, { status: 404 });
  }
  return new NextResponse(null, { status: 204 });
}

上述示例演示了按 id 查询(GET)、整体更新(PUT)、删除(DELETE)。PATCH 可按同样模式从 request.json() 读取部分字段并合并更新。

3. 请求与响应处理

URL 查询参数

import { NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const q = request.nextUrl.searchParams.get("q") ?? "";
  const page = request.nextUrl.searchParams.get("page") ?? "1";
  return NextResponse.json({ q, page });
}

请求体

JSON 使用 await request.json();表单可使用 request.formData();原始文本可用 request.text()

请求头

export async function GET(request: NextRequest) {
  const auth = request.headers.get("authorization");
  const ua = request.headers.get("user-agent");
  return NextResponse.json({ auth, ua });
}

响应头与状态码

import { NextResponse } from "next/server";

export async function GET() {
  const res = NextResponse.json({ ok: true }, { status: 200 });
  res.headers.set("X-Custom-Header", "demo");
  return res;
}

设置 Cookies

import { NextResponse } from "next/server";

export async function POST() {
  const res = NextResponse.json({ ok: true });
  res.cookies.set("session", "abc123", {
    httpOnly: true,
    sameSite: "lax",
    path: "/",
    maxAge: 60 * 60 * 24 * 7,
  });
  return res;
}

读取 Cookie 可用 request.cookies.get("name")(NextRequest)。常用状态码:200 成功、201 已创建、204 无内容、400 客户端错误、401 未授权、404 未找到、500 服务器错误。

4. 中间件 (Middleware)

在项目根目录(若使用 src/ 则放在 src/middleware.ts)创建 middleware.ts。中间件在匹配到的请求进入页面或 Route Handler 之前执行,可用于鉴权、重写、日志、A/B 分流等。

鉴权:检查 Token,未登录重定向

// middleware.ts(与 app/ 同级,或在 src/ 下)
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const token = request.cookies.get("session")?.value;
  const isAuthPage = request.nextUrl.pathname.startsWith("/login");

  if (!token && !isAuthPage) {
    const login = new URL("/login", request.url);
    login.searchParams.set("from", request.nextUrl.pathname);
    return NextResponse.redirect(login);
  }

  if (token && isAuthPage) {
    return NextResponse.redirect(new URL("/dashboard", request.url));
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/dashboard/:path*", "/login"],
};

日志中间件

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";

export function middleware(request: NextRequest) {
  const t = Date.now();
  const res = NextResponse.next();
  res.headers.set("X-Request-Id", crypto.randomUUID());
  console.log(
    `[mw] ${request.method} ${request.nextUrl.pathname} +${Date.now() - t}ms`,
  );
  return res;
}

export const config = {
  matcher: ["/api/:path*"],
};

💡 要点

中间件默认运行在 Edge Runtime:启动快、地理分布友好,但 可用的 Node API 与 npm 包受限(例如不能随意使用仅 Node 的原生模块)。需要完整 Node 运行时逻辑时,应把重逻辑放在 Route Handler、Server Action 或独立后端服务中。

5. CORS 配置

浏览器跨域访问 API 时,服务端需返回正确的 CORS 头。Route Handler 中没有 Express 的 cors 中间件,需手动设置响应头,并对 OPTIONS 预检请求单独响应。

// lib/cors.ts
import { NextRequest, NextResponse } from "next/server";

const ALLOW_ORIGIN = process.env.CORS_ORIGIN ?? "*";
const ALLOW_METHODS = "GET, POST, PUT, PATCH, DELETE, OPTIONS";
const ALLOW_HEADERS = "Content-Type, Authorization";

export function withCors(res: NextResponse) {
  res.headers.set("Access-Control-Allow-Origin", ALLOW_ORIGIN);
  res.headers.set("Access-Control-Allow-Methods", ALLOW_METHODS);
  res.headers.set("Access-Control-Allow-Headers", ALLOW_HEADERS);
  return res;
}

export function jsonWithCors(data: unknown, init?: ResponseInit) {
  return withCors(NextResponse.json(data, init));
}

export function handleCorsPreflight(_request: NextRequest) {
  return withCors(new NextResponse(null, { status: 204 }));
}
// app/api/public/route.ts
import { NextRequest } from "next/server";
import { jsonWithCors, handleCorsPreflight } from "@/lib/cors";

export function OPTIONS(request: NextRequest) {
  return handleCorsPreflight(request);
}

export async function GET(request: NextRequest) {
  return jsonWithCors({ message: "cors ok" });
}

生产环境建议将 Access-Control-Allow-Origin 设为具体前端域名,避免滥用 * 与携带凭证的场景冲突(带 Cookie 时不能使用 *)。

6. 错误处理与验证

对异步解析与业务逻辑使用 try/catch,向客户端返回统一 JSON 结构,避免堆栈泄露到生产环境。

统一错误响应

import { NextResponse } from "next/server";

type ErrBody = { error: { code: string; message: string } };

export function jsonError(code: string, message: string, status: number) {
  const body: ErrBody = { error: { code, message } };
  return NextResponse.json(body, { status });
}

export async function POST(request: Request) {
  try {
    const data = await request.json();
    return NextResponse.json({ data });
  } catch {
    return jsonError("BAD_JSON", "Invalid JSON body", 400);
  }
}

使用 Zod 校验请求体

安装:npm i zod

import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";

const CreateUser = z.object({
  email: z.string().email(),
  name: z.string().min(1).max(64),
});

export async function POST(request: NextRequest) {
  let body: unknown;
  try {
    body = await request.json();
  } catch {
    return NextResponse.json(
      { error: { code: "BAD_JSON", message: "Invalid JSON" } },
      { status: 400 },
    );
  }

  const parsed = CreateUser.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json(
      {
        error: {
          code: "VALIDATION_ERROR",
          issues: parsed.error.flatten(),
        },
      },
      { status: 422 },
    );
  }

  return NextResponse.json({ user: parsed.data }, { status: 201 });
}

7. 完整 RESTful API 示例

下面用内存存储演示文章资源的列表(GET,含分页)、创建(POST),以及按 id 的查询、更新、删除。先将共享存储放在 lib/posts-store.ts,两个 Route Handler 分别引用;实际项目请替换为数据库层。

lib/posts-store.ts

export type Post = {
  id: string;
  title: string;
  body: string;
  createdAt: string;
};

export const posts = new Map<string, Post>();

export function newPostId() {
  return crypto.randomUUID();
}

app/api/posts/route.ts

import { NextRequest, NextResponse } from "next/server";
import { newPostId, posts, type Post } from "@/lib/posts-store";

export async function GET(request: NextRequest) {
  const page = Math.max(1, Number(request.nextUrl.searchParams.get("page") ?? "1"));
  const pageSize = Math.min(
    50,
    Math.max(1, Number(request.nextUrl.searchParams.get("pageSize") ?? "10")),
  );
  const all = Array.from(posts.values()).sort(
    (a, b) => b.createdAt.localeCompare(a.createdAt),
  );
  const start = (page - 1) * pageSize;
  const slice = all.slice(start, start + pageSize);
  return NextResponse.json({
    data: slice,
    meta: { page, pageSize, total: all.length },
  });
}

export async function POST(request: NextRequest) {
  try {
    const body = await request.json();
    const title = typeof body.title === "string" ? body.title.trim() : "";
    const text = typeof body.body === "string" ? body.body : "";
    if (!title) {
      return NextResponse.json(
        { error: { code: "VALIDATION_ERROR", message: "title required" } },
        { status: 400 },
      );
    }
    const id = newPostId();
    const post: Post = {
      id,
      title,
      body: text,
      createdAt: new Date().toISOString(),
    };
    posts.set(id, post);
    return NextResponse.json(post, { status: 201 });
  } catch {
    return NextResponse.json(
      { error: { code: "BAD_JSON", message: "Invalid JSON" } },
      { status: 400 },
    );
  }
}

app/api/posts/[id]/route.ts

import { NextRequest, NextResponse } from "next/server";
import { posts } from "@/lib/posts-store";

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

export async function GET(_request: NextRequest, context: Ctx) {
  const { id } = await context.params;
  const post = posts.get(id);
  if (!post) {
    return NextResponse.json(
      { error: { code: "NOT_FOUND", message: "post not found" } },
      { status: 404 },
    );
  }
  return NextResponse.json(post);
}

export async function PUT(request: NextRequest, context: Ctx) {
  const { id } = await context.params;
  const prev = posts.get(id);
  if (!prev) {
    return NextResponse.json(
      { error: { code: "NOT_FOUND", message: "post not found" } },
      { status: 404 },
    );
  }
  try {
    const body = await request.json();
    const title =
      typeof body.title === "string" ? body.title.trim() : prev.title;
    const text = typeof body.body === "string" ? body.body : prev.body;
    const next = { ...prev, title, body: text };
    posts.set(id, next);
    return NextResponse.json(next);
  } catch {
    return NextResponse.json(
      { error: { code: "BAD_JSON", message: "Invalid JSON" } },
      { status: 400 },
    );
  }
}

export async function DELETE(_request: NextRequest, context: Ctx) {
  const { id } = await context.params;
  if (!posts.has(id)) {
    return NextResponse.json(
      { error: { code: "NOT_FOUND", message: "post not found" } },
      { status: 404 },
    );
  }
  posts.delete(id);
  return new NextResponse(null, { status: 204 });
}

Next.js Route Handlers vs Express.js

  • 路由定义:Next 用文件路径映射 HTTP 路径;Express 用 app.get/post/... 链式注册。
  • 部署模型:Route Handler 与前端同仓库、同构建,可部署到 Vercel 等无服务器/边缘环境;Express 常为长期运行的 Node 进程。
  • 中间件:Next middleware.ts 偏全局、Edge;Express 中间件栈更细粒度、运行在 Node。
  • 适用场景:轻量 BFF、鉴权代理、Webhook 用 Route Handler 很顺手;极重 TCP/WebSocket 自定义或既有 Express 生态可继续用 Express 或并存。

8. 本章要点

Route Handlers

app/api/.../route.ts 导出 HTTP 方法函数;使用 NextRequest / NextResponse 处理请求与响应。

动态路由

Next.js 15 中 context.params 为 Promise,务必 await 后再用。

中间件

根目录 middleware.tsmatcher 控制范围;注意 Edge 运行时 API 限制。

CORS

手动设置响应头并处理 OPTIONS 预检;生产环境收紧 Allow-Origin

错误与校验

try/catch 包装解析逻辑;统一错误 JSON;用 Zod 做请求体验证。

REST 实践

集合与资源分文件;GET 列表加分页查询参数;创建返回 201;删除可用 204