上一课已经把书目读取集中到了 src/lib/data.ts,页面能够稳定地拿到六本书。现在我们要解决一个更接近真实产品的问题:读者筛选出“已读完的城市类书目”以后,怎样把这组结果发给朋友?
如果筛选条件只放在组件 state 里,复制网址、刷新页面或使用浏览器的前进后退都会丢失状态。更合适的做法是把条件放进查询字符串,让 URL 成为这次查询的完整描述。本课会沿着同一条数据路径完成页面搜索和 /api/books:页面负责展示,findBooks 负责规则,Route Handler 负责 HTTP 边界。
学完后,你会得到一张支持关键词与阅读状态筛选的书架页。它不依赖额外的客户端状态,网址可以直接分享;同一套筛选规则也能输出 JSON。
一个 URL 可以粗略分成路径和查询字符串。/books 表示“书架”这个资源,问号后的 q=观察&status=finished 表示查看资源时使用的条件:
查询字符串适合搜索、排序、分页这类“读取条件”。它随网址一起进入历史记录,也能被复制。用户刷新 /books?q=观察&status=finished,服务器仍然知道应该展示哪一组结果。
这也是选择 GET 表单的原因。GET 描述一次读取,浏览器会把有 name 的表单控件编码到 URL 中。POST 更适合会创建或修改数据的操作,我们会在下一课把它交给 Server Action。
查询字符串是公开输入。它可能来自表单,也可能由访问者手写,因此服务器必须校验它,不能把 TypeScript 类型当成运行时保证。
先约定两个参数,不急着写组件:
q:可选关键词;空字符串表示不过滤关键词。status:all、reading、finished 或 planned;缺省时按 all 处理。用这份约定读三个网址:
打开 /books。没有查询参数,页面应该显示全部六本书。
打开 /books?q=观察。关键词会匹配《缓慢观察手册》的书名,也会匹配含“观察”标签的书目。
打开 /books?q=城市&status=finished。两个条件需要同时成立,最后只留下状态为“已读完”、并且带有“城市”信息的书。
现在我们有了一份可执行的 URL 契约:
/books
/books?q=观察
/books?q=城市&status=finished第三个网址的预期结果是《雨后博物馆》。如果把该网址复制给另一位读者,对方不需要重复输入条件,就能看到同一组结果。
页面和接口都会筛选书目。如果各写一遍 filter,两份规则很容易慢慢分叉:页面搜索标签,接口却忘了;页面把非法状态当成“全部”,接口却返回空数组。
第 4 课已经把“怎样查书”放进数据模块,第 6 课加入缓存时也保留了这层边界。现在页面只负责把输入整理成 query 和 status,findBooks 负责执行规则。它继续复用带缓存标签的 getBooks(),而不是绕开数据层直接读取 JSON 文件。
打开 src/lib/data.ts,复核 BookStatus 导入和现有 findBooks 是否与下面一致。这里是在使用前确认唯一实现,不要重复粘贴第二个同名函数;若此前代码有差异,就用这段替换原函数。
import type { Book, BookStatus, ReadingNote } from "@/lib/types";
export async function findBooks(filters: {
query?: string;
status?: BookStatus | "all";
}): Promise<Book[]> {
const query = filters.query?.trim().toLocaleLowerCase("zh-CN") ?? ""
这里有三处有意为之的细节:
trim() 去掉输入首尾空白,避免“ 观察 ”查不到结果。toLocaleLowerCase("zh-CN") 让 Web 和 web 这类拉丁字符匹配一致。matchesStatus && (!query || haystack.includes(query)) 表示状态条件与关键词条件要同时成立;关键词为空时,右半部分自然通过。同一函数可以回答几种不同问题:
await findBooks({}); // 6 本
await findBooks({ query: "观察", status: "all" }); // 2 本
await findBooks({ status: "reading" }); // 2 本
await findBooks({ query: "城市", status: "finished" }); // 1 本最后一行返回《雨后博物馆》。筛选规则已经脱离页面,后面创建 JSON 接口时不需要复制业务代码。
在 Next.js 16 的 App Router 中,页面收到的 searchParams 是 Promise。它和动态路由的 params 一样,需要 await 后再读取。searchParams 解析出来的是普通对象,并不是 URLSearchParams 实例。同名参数出现一次时值是字符串,重复出现时可能是字符串数组,因此输入类型还要覆盖 string[]。
这个变化有实际含义:查询字符串属于请求到达后才能确定的数据。把读取动作放在异步 Server Component 中,Next.js 才能判断哪部分需要等请求、哪部分仍可提前生成。
HTML 表单在省略 method 时默认使用 GET。输入框和下拉框只要有 name,提交时就会生成 q 与 status。defaultValue 则让刷新或分享后重新打开页面时,控件仍然显示 URL 里的当前条件。
创建 src/app/books/page.tsx。先实现查询参数类型、结果骨架和真正读取参数的 BookResults:
import type { Metadata } from "next";
import { Suspense } from "react";
import { BookCard } from "@/components/book-card";
import { findBooks } from "@/lib/data";
import type { BookStatus } from "@/lib/types";
export const metadata: Metadata = {
title: "书架",
description: "按关键词和阅读状态查找拾光书架中的书。",
这段页面代码要分五层阅读。
第一层是固定元数据。metadata 的输入在编写时已经确定,Next.js 会把标题与摘要合入页面 <head>;它不参与搜索逻辑,也不会进入表单提交值。
第二层是公开输入类型。页面 prop 中的 searchParams 是 Promise,解析后的单个属性可能是字符串、字符串数组或 undefined。firstValue 把这三种情况统一为 string | undefined。这里选择重复参数的第一个值,是公开搜索页明确的产品契约;更严格的支付回调不应照搬,而应拒绝重复值。
第三层是输入收窄。关键词先截取前 80 个字符,状态则必须出现在白名单中。浏览器的 maxLength={80} 改善正常输入体验,服务器的 .slice(0, 80) 才能限制手写 URL。二者必须同时存在,因为网络请求可以绕过 HTML 属性。as BookStatus 本身没有校验能力,只有在白名单判断为真以后,这个断言才与运行时事实一致。
第四层是 GET 表单。method="get" 告诉浏览器把拥有 name 的控件编码进查询字符串;action="/books" 指定提交目标。defaultValue 让输入框和下拉框以 URL 为初始来源,同时保持为非受控表单,浏览器可以直接完成提交。若误用固定的 value 却不提供 onChange,控件会变成无法编辑的只读状态。
第五层是结果渲染。findBooks 的输出始终是数组,因此页面可以用 books.length 在卡片网格与空状态之间选择。aria-live="polite" 会在结果数量改变后温和播报;每张 BookCard 使用稳定 slug 作为 key,不能使用数组下标代替。
完整数据流是:地址栏查询字符串 → 异步 searchParams → 取首值与长度/状态校验 → findBooks → 缓存书单 → 数组筛选 → 数量、卡片或空状态。页面展示和输入框都由同一个 URL 推导,所以刷新、返回与分享不会丢失条件。
“取第一个值”是这个公开搜索页选择的契约,不是唯一答案。登录回调、支付参数等严格入口更适合直接拒绝重复值,并返回明确的 400 响应。
输入“观察”、选择“已读完”并提交后,地址栏会变成:
/books?q=观察&status=finished页面显示“找到 1 本书”,输入框仍是“观察”,下拉框仍是“已读完”。按刷新键或使用浏览器前进、后退,这三个状态都保持一致:URL、表单和结果没有分家。
即使访问 /books?q=城市&q=观察&status=finished,页面也会稳定采用第一个关键词“城市”,不会因为 q 变成数组而报错。
下面是真实搜索结果。URL 为 http://localhost:3000/books?q=城市&status=finished,视口为 1440×900;直接打开该地址并等待卡片稳定后捕获。观察输入框保留“城市”、状态为“已读完”、数量为 1,页面只显示《雨后博物馆》。

Suspense 不是一个“加载动画开关”,而是一条渲染边界。边界外的标题和说明可以先输出;边界内的 BookResults 要等待 searchParams 和书目查询。等待期间,React 用 fallback 占住结果区域,数据准备好后再流式补上。
本项目启用了 Cache Components。查询字符串是运行时数据,把读取它的组件放进 Suspense,可以保留页面外壳的预渲染能力。生产构建会把 /books 标记为部分预渲染,而不是把整个页面都推迟到请求时。
src/app/books/loading.tsx 仍然有用:它负责整段路由导航时的反馈;这里的嵌套 Suspense 更精确,只替换结果区域。
在同一个 src/app/books/page.tsx 末尾加入页面组件:
export default function BooksPage({
searchParams,
}: {
searchParams: SearchParams;
}) {
return (
<div className="mx-auto max-w-6xl px-5 py-14 lg:px-8">
<p className="eyebrow">可分享的查询结果</p>
<h1 className="mt-3 font-serif text-4xl font-bold sm:text-5xl">
书架
</h1
注意页面组件本身没有 await searchParams。Promise 被原样传到边界内,真正读取它的是 BookResults。如果在边界外先 await,等待就会重新扩大到整个页面。
首次进入书架时,标题“书架”和用途说明可以先出现,结果区短暂显示三张骨架卡片。随后表单、数量和卡片替换骨架。
生产构建中的标记会是:
◐ /books◐ 表示 Partial Prerender:静态外壳已经生成,请求相关的结果由服务器流式补充。
页面返回 HTML,其他程序更常需要 JSON。App Router 用 route.ts 定义 Route Handler,它基于 Web 标准的 Request 和 Response 工作,并按导出的函数名匹配 HTTP 方法。
文件 src/app/api/books/route.ts 对应 /api/books。导出 GET 后,访问者可以用与页面相同的 q、status 查询数据。Route Handler 不是“绕过页面的内部函数”,它是一个可以被直接请求的公开 HTTP 入口。
Next.js 16 默认不会把普通 GET Route Handler 的完整响应自动静态缓存,所以构建结果会把 /api/books 标为动态路由。不过它调用的 findBooks 仍然复用 getBooks 的缓存数据。
创建 src/app/api/books/route.ts:
import { NextResponse } from "next/server";
import { findBooks } from "@/lib/data";
import type { BookStatus } from "@/lib/types";
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const rawStatus = searchParams.get("status") ??
Route Handler 收到的是完整 Request,因此这里用 new URL(request.url).searchParams。与页面 prop 不同,它是真正的 URLSearchParams;get("q") 始终返回第一个字符串或 null。
这个函数的输入是一条 HTTP GET 请求,输出是 JSON 响应。new URL 负责解析地址,状态白名单负责把未知字符串收窄为业务允许值,关键词继续使用与页面相同的 80 字符上限,findBooks 执行唯一筛选规则,最后 NextResponse.json 序列化 { count, books } 并设置 JSON 响应头。
虽然 Handler 自己没有读取文件,它导入的数据层使用了 node:fs,因此不要为这个路由设置 Edge runtime。另一个常见错误是只在页面限制关键词,却让 API 接受无限长输入;那会让两个入口的契约重新分叉。
访问 /api/books?q=城市&status=finished,响应状态是 200,Content-Type 是 application/json,主体结构如下:
{
"count": 1,
"books": [
{
"slug": "museum-after-rain",
"title": "雨后博物馆",
"author": "顾清和",
"category": "艺术",
"status": "finished",
"accent": "violet",
"rating": 4.5,
"summary": "二十四件虚构藏品串起一座城市的记忆,文字短小,线索彼此照应。",
页面和接口都返回同一本书,因为它们共享 findBooks,没有维护两套筛选规则。
Route Handler 的 URL 能被任何客户端直接调用。隐藏导航入口、不给按钮或把代码放在服务器文件里,都不等于权限控制。判断接口是否足够安全,要逐项看输入、身份、权限、输出和资源消耗。
当前 /api/books 读取的是准备公开展示的六本书,没有用户私有数据,也没有写操作。因此它可以匿名访问。代码仍然对 status 使用白名单,并让 q 只参与内存字符串匹配。以后若改成数据库查询,要继续使用参数化查询,不能把关键词拼接进 SQL。
如果接口开始返回私人书单或支持修改数据,就必须在 Route Handler 内重新验证会话和权限。客户端已经隐藏按钮不是依据;服务端入口自己负责判断。
对当前接口做一次边界审查:
确认只导出了 GET。没有 POST、PATCH 或 DELETE,这个入口不会修改数据。
确认 status 经过允许值列表。未知值会回退到 all,不会进入业务类型。
确认响应字段都是书架页面已经公开的内容。密钥、文件路径、内部错误堆栈和用户信息都不应出现在 JSON 中。
为未来变化留下规则:私有数据先认证再授权;高流量入口增加速率限制;长关键词设置长度上限;写操作再次校验全部字段。
TypeScript 只检查我们写代码时的类型,不能验证网络请求。as BookStatus 本身没有安全作用;真正的边界是它前面的运行时白名单。
手写 /api/books?status=unknown 不会让服务器报错,响应会按“全部状态”处理并返回 count: 6。请求一个未实现的写方法也不会执行任何数据修改。
这个结果适合当前公开、只读的教学项目。若业务边界改变,验收条件也必须跟着改变,不能沿用“公开读取”的结论。
一个功能完成,不等于“页面看起来有输入框”。我们要验证完整链路:输入是否进入 URL,URL 是否驱动服务器查询,刷新和分享是否复现结果,空结果是否可理解,JSON 是否与页面一致。
这条链路也说明了 App Router 的分工:
GET 表单
→ URL 查询字符串
→ 异步 searchParams
→ findBooks
→ HTML 结果
同一组参数
→ Route Handler
→ findBooks
→ JSON 结果依次完成下面的验收:
/api/books,确认 count 与页面上的数量相同。完成验收以后,再清空关键词、只选择“正在读”。原生 GET 表单会把空输入框也序列化,所以地址会变为 /books?q=&status=reading,页面显示两本书。服务端把空字符串当作没有关键词,它与省略 q 的 /books?status=reading 得到相同结果。若产品必须生成后一种更短的网址,需要额外用客户端代码在提交前删除空参数,当前课程没有隐藏这一步。
试着先回答这个问题:如果产品希望输入时即时搜索,同时仍保留可分享 URL,你会把即时交互放在哪里?
下一课会沿用同样的边界思路处理写入:表单可以很轻,但服务器必须校验输入、完成原子写入,并主动让读者看到刚刚提交的新内容。