N10第 1 / 1 课时未开始内容版本 1.0.0 · 核验于 2026-10-26

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 校验直接失败。

动手任务

在本机工程里完成:

  1. 实现上面的 GET 接口,用 curl 验证正常分页与 page=0 的 400。
  2. 请求 pageSize=999,确认被 max(50) 拦成 400 而不是全表返回。
  3. 加一个 sort 参数(支持 time/relevance),非法取值时决定报错还是回退默认,并把行为写进注释或文档。

常见错误

  • 忘了 force-dynamic:构建时接口被预渲染成静态 JSON,数据库更新后接口永远发旧数据。
  • 把 searchParams 的字符串直接当数字用:OFFSET 收到字符串,node:sqlite 抛绑定类型错误;先校验转换再入库。
  • 错误响应没有固定结构:调用方无法统一判断失败;错误也该有 schema——固定的 error 字段。
  • catch 所有异常后返回 200:调用方以为成功,脚本静默拿到坏数据;服务端异常应当返回 500 并记日志。

检查与复盘

自查清单:

  • 接口的 200/400/500 出口都真实触发并验证过
  • 响应结构有明确合同(注释或文档),调用方按形状解析
  • 参数校验发生在任何数据库操作之前

参考资料

检查题共 2 题,提交后立即显示解析
答案由服务端判定;答错的题会自动进入复习计划。
Q1读代码中等

阅读笔记创建接口。分别 POST { title: '' } 和 POST { title: '学习笔记' },客户端两次拿到的 HTTP 状态码是?

import { NextResponse } from 'next/server';

export async function POST(req: Request) {
  const body = await req.json();
  if (!body.title?.trim()) {
    return NextResponse.json(
      { error: '标题必填' },
      { status: 400 }
    );
  }
  const id = createNote(body.title);
  return NextResponse.json({ ok: true, id }, { status: 201 });
}
Q2故障定位较难

bug 现场:笔记更新接口明明写了校验,但客户端用非法数据请求时,收到的是 200 和成功响应,而且脏数据被写进了数据库。原因在哪?

export async function PUT(req: Request, { params }) {
  const { id } = await params;
  const parsed = schema.safeParse(await req.json());
  if (!parsed.success) {
    // 校验失败分支
    NextResponse.json({ error: '参数不合法' }, { status: 400 });
  }
  updateNote(id, parsed.data); // 非法请求时仍然执行
  return NextResponse.json({ ok: true });
}

读完这一课了?

标记完成后,工作台会安排接下来的学习。

Command Palette

Search for a command to run...