Route Handlers
Route Handlers · 阅读后完成下方实验与检查题,再用「标记完成」记录进度。
学习目标
页面渲染帮不了外部脚本和小工具——它们要的是 JSON 接口。本课在 App Router 里写一个带分页、校验和错误处理的 Route Handler,并用真实请求把每个分支都核验一遍。
问题场景
笔记列表已经有页面版了,但你想写个脚本批量检查笔记,或者给将来要做的小工具留数据源。需要的是稳定接口:GET /api/notes?page=2&pageSize=10。要求很朴素——参数不合法返回 400,页码超范围不崩,响应结构永远可预测。
核心解释
Route Handler 就是放在 app/api/**/route.ts 里的导出函数:导出 GET 就处理 GET 请求,导出 POST 就处理 POST,用 NextResponse.json 返回响应。
分页接口的完整形态:
// app/api/notes/route.ts
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { getDb } from "@/lib/db/client";
export const dynamic = "force-dynamic";
const QuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(50).default(10),
});
export async function GET(req: NextRequest) {
const parsed = QuerySchema.safeParse({
page: req.nextUrl.searchParams.get("page") ?? undefined,
pageSize: req.nextUrl.searchParams.get("pageSize") ?? undefined,
});
if (!parsed.success) {
return NextResponse.json(
{ error: { code: "invalid_param", message: "分页参数不合法" } },
{ status: 400 }
);
}
const { page, pageSize } = parsed.data;
const db = getDb();
const total = (db.prepare("SELECT COUNT(*) AS n FROM note").get() as { n: number }).n;
const items = db
.prepare("SELECT id, title, updated_at FROM note ORDER BY updated_at DESC LIMIT ? OFFSET ?")
.all(pageSize, (page - 1) * pageSize);
return NextResponse.json({ page, pageSize, total, items });
}
三个设计决定值得说透:
- 校验放最前面。zod 把字符串形式的查询参数转成带约束的数字,任何不合法输入在碰数据库之前就被 400 挡下。
- 状态码表达结果类别:200 成功、400 调用方参数错、413 资源过大、500 服务端问题。别什么都返回 200 再在 body 里写 error。
- 响应结构稳定。调用方永远拿到
{ page, pageSize, total, items }或{ error: { code, message } }两种形状之一,没有第三种。
发起真实请求核验(npm run dev 后另开终端):
curl -s "http://localhost:3000/api/notes?page=1&pageSize=2"
curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:3000/api/notes?page=0"
第一条返回 JSON 数据,第二条应打印 400——这两条通过,接口的合同就算立住了。
示例拆解
关键点逐条过:
route.ts不能与page.tsx放在同一目录:一个路由段要么是页面要么是接口,不共存。export const dynamic = "force-dynamic":接口读数据库,每次调用都要新数据,禁止在构建期被预渲染成一份静态 JSON。LIMIT ? OFFSET ?用参数占位,页码换算(page - 1) * pageSize放在参数里而不是拼进 SQL 字符串。z.coerce.number()处理的是「查询参数全是字符串」的现实;不加它,page=2会以字符串身份撞上 int 校验直接失败。
动手任务
在本机工程里完成:
- 实现上面的 GET 接口,用 curl 验证正常分页与 page=0 的 400。
- 请求 pageSize=999,确认被 max(50) 拦成 400 而不是全表返回。
- 加一个 sort 参数(支持 time/relevance),非法取值时决定报错还是回退默认,并把行为写进注释或文档。
常见错误
- 忘了 force-dynamic:构建时接口被预渲染成静态 JSON,数据库更新后接口永远发旧数据。
- 把 searchParams 的字符串直接当数字用:OFFSET 收到字符串,node:sqlite 抛绑定类型错误;先校验转换再入库。
- 错误响应没有固定结构:调用方无法统一判断失败;错误也该有 schema——固定的 error 字段。
- catch 所有异常后返回 200:调用方以为成功,脚本静默拿到坏数据;服务端异常应当返回 500 并记日志。
检查与复盘
自查清单:
- 接口的 200/400/500 出口都真实触发并验证过
- 响应结构有明确合同(注释或文档),调用方按形状解析
- 参数校验发生在任何数据库操作之前
参考资料
- Next.js 文档:Route Handlers —— 路由处理函数的官方指南
- Next.js 文档:Route Segment Config —— dynamic 等段级配置项
- zod 文档 —— 本项目使用的输入校验库
读完这一课了?
标记完成后,工作台会安排接下来的学习。