
这两年用 Next.js 13 做了不少内部工具类的小项目最让我觉得顺手到离谱的组合就是拿它配 SQLite3 做带查找功能的网页。数据量不大、结构清晰、需要快速交付的时候这套方案几乎成了我默认的起手式。这篇文章把我最近一次完整搭建的过程摊开讲从选型理由、建表思路到查询细节、页面实现再到底层部署之后踩过的各种坑一次性都记录下来。如果你正准备做一个中小数据量、以搜索和展示为主的网页应用尤其是不想为数据库单独维护一台服务器那这篇内容应该能帮你省不少时间。读完你不仅能搭出一个能用的查询页面还能理解每一步操作背后的原因而不是照抄代码后出了问题干瞪眼。1. 项目设计思路为什么这个查找网页选 Next.js 13 SQLite31.1 场景决定选型中小型数据量查找页的真实需求先说清楚适用范围免得有人盲目照搬。我这次做的项目需求其实特别朴素一个内部工具站点维护了一份核心业务数据用户需要在网页上输入关键字快速检索出匹配的记录并且支持分页浏览和简单详情查看。预估数据量在几万条以内写入频率极低绝大部分时间都是只读查询。这种场景下上 MySQL 或者 PostgreSQL 当然可以但复杂度一下子起来了要部署独立的数据库服务要考虑连接池、权限管理、备份策略还要在开发环境、测试环境、生产环境之间同步结构。对一个内部工具来说实在没必要背这种重装备。SQLite3 恰好就是为这种场景设计的它是一个文件型数据库整个数据库就是一个本地文件不需要独立进程应用代码里直接打开文件就能读写。配合 Node.js 生态几万条数据做模糊查询毫秒级返回完全没问题。而且它的事务能力是齐全的就算后续要加一些简单写入也不需要换库。类比一下这就像你在家做饭不需要架一口饭店级别的大锅一把趁手的炒锅就够了。选型不是越强大越好而是和你的菜量匹配。SQLite3 就是这把炒锅。1.2 Next.js 13 的 App Router 给这个组合带来什么既然数据层选定了 SQLite3前端框架为什么是 Next.js 13这个版本最大的变化就是 App Router 和 Server Component它们让前端页面服务端逻辑的边界被重新画了一遍。传统的 React 项目哪怕用了数据请求库页面上展示的数据还是要经历一个流程浏览器渲染页面框架 → 页面组件发起 fetch → 后端 API 查询数据库 → 返回 JSON → 前端再 setState → 页面才真正显示出来。这套链路没什么不对但它有大量可优化的空间多了一次网络往返、多了一份重复的请求逻辑、还要处理加载状态和错误状态。在 Next.js 13 的 App Router 模式下页面默认就是服务端组件数据库查询可以直接写在组件函数里。服务端把查询结果直接渲染成 HTML 字符串浏览器拿到的就是已经填充好的页面。对于查找这种典型场景这个特性几乎是量身定做的。另外Next.js 自带路由、布局、环境变量管理等基础设施整个查找应用就是一个单体项目不用像传统方案那样把前端、后端拆成两个仓库去部署。这也是我选它的现实原因一个人维护整个项目少一个部署单元就少一堆麻烦。1.3 数据流向与项目目录的完整规划架构上我做了很明确的分层页面路由层、数据访问层、数据库文件层。页面路由层只负责接收参数和渲染 UI数据访问层封装所有 SQL 操作数据库文件单独放在 data 目录下。这样一旦未来数据量暴增需要切换数据库所有改动都能收敛在数据访问层里。实际项目的目录结构大致是下面这样my-search-app/ ├── app/ │ ├── layout.tsx │ ├── page.tsx │ └── search/ │ └── page.tsx ├── lib/ │ ├── db.ts │ └── records.ts ├── data/ │ └── app.db ├── scripts/ │ └── migrate.ts └── package.json数据流向很直接用户在搜索表单输入关键字 → 表单以 GET 方式提交到 /search 路径 → URL 查询参数里带上 keyword 和 page → 服务端组件读取参数 → 调用 lib/records.ts 里的查询函数 → 执行 SQL 查询 SQLite3 文件 → 把结果渲染到页面。整条链路没有多余环节出了问题排查起来也很清晰。2. 从零搭建初始化项目、建表与种子数据写入2.1 创建 Next.js 13 项目并装好 SQLite3 依赖项目初始化直接用官方脚手架一行命令搞定。我一般会开启 TypeScript因为从数据库返回的行结构需要类型约束有类型提示写查询会省心非常多而且重构字段时也能尽早发现错误。npx create-next-applatest my-search-app --typescript --eslint --app接着安装 SQLite3 的 Node 驱动。这里要特意说一下选型Node 生态里有两个主流包老牌的sqlite3和新一点的better-sqlite3。我最终选 better-sqlite3核心原因是它的 API 是同步的代码写起来非常直白没有回调嵌套和 Promise 链。用老牌sqlite3写一个简单查询要先包一层 Promise写起来绕来绕去。better-sqlite3 直接在一条语句里完成查询性能和安全性都不差。npm install better-sqlite3 npm install -D types/better-sqlite3安装 TypeScript 类型声明这一步也别漏否则 import 进来全是 any类型保护就形同虚设了。2.2 建库建表把查找字段设计放在第一位表结构设计是这整个项目的基石。我的表设计原则只有一个每个字段都要回答它会不会被用来查找这个问题。这次我用了一张 records 表核心字段包括ID、标题、类别、内容摘要、创建时间。其中标题和摘要就是搜索要匹配的重点字段类别是一个高频筛选字段创建时间用做默认排序。ID 用自增主键方便详情页做链接跳转。多说一句很多人在设计表时会把字段加得特别多但实际上查找页能用到的就那么几个。字段越多写入时维护成本越高索引占用的空间也越大。对小项目来说克制加字段是正确的。创建表的 SQL 我写在脚本里而不是手动去数据库里敲原因后面会说。SQL 大概是这样的CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, category TEXT NOT NULL, summary TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime(now)) );2.3 初始化脚本与种子数据批量导入强烈建议把建表和种子数据的脚本写进仓库而不是只在本地手动操作。这背后的逻辑很简单开发环境、测试环境、同事的电脑、未来的生产环境都需要同一个数据库结构。手动操作一次两次还行次数多了必出偏差。初始化脚本我用 Node tsx 来跑里面做了三件事打开数据库文件、执行建表语句、批量插入种子数据。脚本运行完data 目录下就会生成 app.db 文件。种子数据的写法也有一点讲究。我批量插入时会用事务包裹几十条数据的批量 INSERT 用事务包起来比一条条插入快好几倍而且中途失败能够回滚不会留下一堆半截数据。import Database from better-sqlite3; import { mkdirSync } from fs; import { dirname } from path; const db new Database(data/app.db); db.exec( CREATE TABLE IF NOT EXISTS records ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, category TEXT NOT NULL, summary TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime(now)) ); ); const seedData [ // 填充模拟数据每条都尽量保持字段完整 ]; const insert db.prepare( INSERT INTO records (title, category, summary) VALUES (?, ?, ?) ); const runSeed db.transaction((items) { for (const item of items) { insert.run(item.title, item.category, item.summary); } }); runSeed(seedData);这里用到的db.transaction是 better-sqlite3 的事务接口它接收一个函数在这个函数里执行的所有写操作会被视为一个整体要么全部成功要么全部回滚。2.4 给高频查询字段建立索引建表之后就该考虑索引了。很多人喜欢在出问题之后才想起来加索引但索引这东西设计阶段稍微想一下后面能省掉很多性能排查时间。我这次给 category 字段建了普通索引因为它是筛选频率最高的字段。给 created_at 也建了索引因为默认排序用到它。但注意一个关键点如果搜索场景用的是LIKE %关键字%这种将通配符放在开头的写法任何标准 B-Tree 索引都帮不上忙因为索引是按照从左到右的顺序匹配的开头是通配符就意味着只能逐个扫描。如果业务上能接受以某个关键字开头的搜索方式把查询改成LIKE 关键字%那就能命中索引了。这是一个在真实项目里非常典型的优化点很多人在网上抄了 LIKE 查询代码就跑结果大数据量下越跑越慢还以为是 SQLite 不给力。3. 查询层的核心实现连接管理、安全与性能3.1 数据库连接做成单例避免请求并发打爆文件 IONext.js 服务端组件在长期运行的 Node 进程里数据库连接不能每个请求都新建。每次 new Database 都会重新打开文件、初始化内部结构并发一上来磁盘 IO 很快就吃不消。我习惯在模块顶层用单例模式管理连接整个进程只保留一个实例。better-sqlite3 的构造是同步的直接在模块加载时创建即可。但在 Next.js 开发模式下模块可能被多次热加载所以要把连接实例挂到全局对象上避免热更新时创建多个连接。import Database from better-sqlite3; import path from node:path; const globalForDb globalThis as unknown as { db?: Database.Database }; export function getDatabase() { if (!globalForDb.db) { const db new Database(path.join(process.cwd(), data, app.db)); db.pragma(journal_mode WAL); globalForDb.db db; } return globalForDb.db; }这里我还执行了journal_mode WAL让 SQLite 切换到预写日志模式。它最大的收益是读操作和写操作可以并发执行查询不会被偶发的写入阻塞。对查找页面这种读多写少的场景这个 pragma 几乎是必开的。另外注意路径写法path.join(process.cwd(), data, app.db)而不是写死data/app.db。原因我后面排查环节会专门讲这里先埋个伏笔。3.2 参数化查询防止用户输入变成 SQL 代码查找功能里用户输入的关键字会直接参与 SQL 拼接这是最经典的注入入口。新手最常见的错误是把字符串变量直接拼进 SQL 模板// 危险写法千万别这样 const rows db.prepare( SELECT * FROM records WHERE title LIKE %${keyword}% ).all();这种写法一旦遇到用户输入 OR 11 --SQL 语句就变成了查询全部数据。这不是理论风险而是真实发生过无数次的安全事故。正确写法是使用参数占位符。better-sqlite3 支持?位置占位符和命名参数我建议用命名参数可读性会好很多。查询语句的骨架和数据通过两条通道送到数据库引擎用户输入永远是数据永远不可能被解释成代码。这个思维一旦建立SQL 注入类问题就能从根上杜绝。const stmt db.prepare( SELECT * FROM records WHERE title LIKE keyword OR summary LIKE keyword ORDER BY created_at DESC LIMIT limit OFFSET offset ); const rows stmt.all({ keyword: %${keyword}%, limit: pageSize, offset: (page - 1) * pageSize, });3.3 关键字查找、分页和排序的 SQL 写法核心查询函数我放在 lib/records.ts 里对外只暴露两个方法searchRecords 和 getRecordById。searchRecords 返回的是分页结果对象包含当前页数据列表和总条数。总条数用来渲染分页页码和共找到多少条的提示。分页查询涉及两次数据库操作一次 count 统计总条数一次 select 取当前页数据。不要尝试用一个 SQL 同时拿两样东西也不要先全量查出来再去内存里分页前者语法别扭后者浪费严重。COUNT 查询同样要用参数绑定关键字和筛选条件要与列表查询保持一致否则分页数字和列表内容就对不上。索引和分页配合时还有个细节如果偏移量特别大比如用户翻到了第 10000 页SQLite 要做很多无效扫描才能跳过前面的数据。对中小数据量这个开销可以接受但如果未来数据涨上去了可以考虑用上次看到的位置这种游标分页替代 OFFSET 分页。现在先把标准方案跑通知道有这条优化路径就够了。3.4 用 EXPLAIN QUERY PLAN 验证查询是否走了索引写完查询之后我习惯在初始化脚本里跑一次 EXPLAIN QUERY PLAN确认 SQLite 实际执行计划不是全表扫描。这个验证成本极低但对性能排查特别有价值。EXPLAIN QUERY PLAN SELECT * FROM records WHERE category demo AND title LIKE hello% ORDER BY created_at DESC;执行结果会明确告诉你用的是SCAN records还是SEARCH records USING INDEX。看到 SCAN 字样就要警觉说明这条查询在逐行扫描整张表。频繁执行的查询要是出现 SCAN就必须考虑加索引或改写语句了。我自己踩过的一个小坑是索引本身没问题但因为查询条件里写了WHERE category LIKE demo%通配符开头导致索引失效EXPLAIN 结果显示扫描。改成等值判断后立刻变成索引查找。这一来一回性能可能差出数量级。4. 查找网页的页面实现服务端组件、搜索表单与分页4.1 用 Server Component 直接读库渲染结果Next.js 13 App Router 下的页面组件默认是 Server Component这意味着它只跑在服务端组件代码里可以直接访问数据库。新建一个 app/search/page.tsx里面导入查询函数拿到结果后直接渲染 JSX这就是一整个查找页面的服务端数据链路。这个写法的好处很实际浏览器收到的 HTML 里已经包含了搜索结果不需要额外的 fetch、loading、error 状态。用户看到首屏的时间就是服务端查询完成的时间。对于查找这种以结果为导向的页面这种模式天然合理。需要注意的一个细节是 searchParams 属性。Next.js 会把它以普通对象形式传进来但同名参数多次出现时值会变成数组。我在组件的入口做一层统一处理把 keyword 和 page 全部转成字符串或默认值后续代码就不用担心类型问题了。4.2 搜索表单设计为什么用原生表单反而更合理搜索交互我见过很多种实现防抖、自动补全、即时搜索、按 Enter 触发的受控组件……但这次我刻意使用了最朴素的原生 HTML 表单。这个选择不是偷懒而是基于业务复杂度的判断。项目的查找诉求非常明确用户输入关键字点按钮或回车然后看到一整页搜索结果。这不是联想搜索也不需要边输入边出建议。使用原生表单浏览器会自动把输入框内容编码成查询参数拼到 URL 上页面会以 GET 方式跳转。带来的额外好处是可分享任何一次搜索结果都有固定的 URL复制给同事对方打开看到的是同一页数据。这种能力在纯客户端搜索方案里反而很难做到。当然如果后续需要输入即搜这种交互再升级成客户端组件也不迟。架构上服务端组件和客户端组件可以灵活嵌套搜索框单独抽出来变成一个 Client Component 即可。现在这个阶段越简单越好。4.3 处理 searchParams、分页和空状态页面组件的核心逻辑集中在三件事解析参数、调用查询、渲染结果。分页部分单独抽一个小函数生成页码链接每页 10 条记录最多显示 5 个页码。分页参数的安全处理也很必要。parseInt 之后要判断是不是 NaN并且用 Math.max(1, value) 把 page 限制在 1 以上。如果不做限制用户手动改 URL 输入 -1 或 0offset 会变成负数最终 SQL 会报错。空状态我单独做了判断搜索词为空时直接显示提示文案 请输入关键字后再查找而不是去执行一次全表扫描。有人可能觉得几万条数据全查也无所谓但没必要做的查询一次都不做是更稳健的习惯。用户搜完没有任何匹配时页面显示 没有匹配的记录请尝试换一个关键词这远比一个空白页友好得多。4.4 完整页面代码示例与效果说明我贴一个精简版的核心代码你去掉业务字段后可以直接套用。// app/search/page.tsx import { searchRecords } from /lib/records; interface SearchPageProps { searchParams: Recordstring, string | string[] | undefined; } export default async function SearchPage({ searchParams }: SearchPageProps) { const keywordParam searchParams?.keyword; const keyword typeof keywordParam string ? keywordParam.trim() : ; const pageParam searchParams?.page; const page typeof pageParam string ? Math.max(1, parseInt(pageParam, 10) || 1) : 1; const pageSize 10; const result keyword ? searchRecords({ keyword, page, pageSize }) : null; return ( main classNamep-6 max-w-3xl mx-auto h1 classNametext-2xl font-bold mb-4记录查找/h1 form methodGET action/search classNamemb-6 flex gap-2 input typesearch namekeyword defaultValue{keyword} placeholder输入关键字... classNameflex-1 border rounded px-3 py-2 / button typesubmit classNamebg-blue-600 text-white rounded px-4 py-2 查找 /button /form {keyword !result p请输入关键字后再查找/p} {result result.total 0 ( p没有匹配的记录请尝试换一个关键词。/p )} {result result.total 0 ( p classNamemb-4 text-sm text-gray-600共找到 {result.total} 条记录/p ul classNamespace-y-3 {result.items.map((item) ( li key{item.id} classNameborder rounded p-4 a href{/records/${item.id}} classNametext-blue-700 font-semibold {item.title} /a div classNametext-xs text-gray-500 mt-1{item.category}/div p classNametext-sm mt-2{item.summary}/p /li ))} /ul {/* 分页链接生成逻辑 */} / )} /main ); }这个页面的效果实测下来就是提交表单后 URL 变成 /search?keywordxxxpage1页面直接渲染出结果列表。因为没有客户端路由跳转带来的白屏过程体验上更像是传统服务端页面稳定且直观。5. 部署与排查SQLite3 在真实环境里的几个大坑5.1 托管平台只读文件系统导致写入失败如果你把这个应用部署到 Serverless 托管平台一件非常容易踩的事就是写入失败。本地开发一切正常一到线上页面也能打开但一旦需要写数据库程序立刻报错提示文件系统只读。原因在于不少云平台的无服务器运行环境里文件系统是只读或者临时的。应用每次冷启动都可能被调度到不同实例上哪怕写入成功文件也不是持久的下次请求可能就读不到了。SQLite3 这种依赖本地文件持久化的方案在这种环境里天然水土不服。我这次的做法是开发阶段就用 SQLite3部署到一台自己掌握文件系统的虚机上数据库文件挂到持久化磁盘路径。如果你的平台只支持无服务器模式又非得上 SQLite 不可那就得考虑托管的 SQLite 服务或者把数据层换掉。核心经验是在设计之初就要把数据库访问封进独立模块这样切换存储方案时只动一个文件页面代码完全不用改。5.2 原生模块编译失败与路径问题的修复better-sqlite3 是个带原生组件的模块不同操作系统、不同 Node 版本下需要重新编译。换电脑开发或者部署到镜像环境之后启动报错是最常见的问题Cannot find module ../build/Release/better_sqlite3.node这个报错基本就是原生二进制和当前平台不匹配。先别急着改代码清掉 node_modules重新安装依赖或者显式执行一次重新编译大部分情况都能解决。如果用了多阶段构建的容器镜像记得最终运行阶段同样要安装生产依赖不能只把编译产物拷贝过去就完事。另外还有一个隐蔽的路径问题开发时我们用data/app.db这种相对路径本地启动正常。部署到线上后进程的工作目录一变SQLite 找不到数据库文件查询结果就静默地返回空列表。这种问题不报错、难察觉我曾经排查了大半天。最后的解法就是第 3 章里写的统一用path.join(process.cwd(), data, app.db)拼绝对路径。这个习惯应该成为写这类代码的肌肉记忆。5.3 并发写锁与查询异常排查清单SQLite3 虽然轻量但在并发写方面有硬性限制同一时间只有一个事务能写库。如果应用里有定时任务和用户写入同时发生偶发会出现database is locked的报错。开着 WAL 模式只能让读写并发不代表写写并发。我遇到的真实情况是一个后台定时脚本每小时更新一批数据恰好和某个管理端的手动更新操作撞上偶发的锁错误让操作人员以为功能坏了。后来我把所有写操作收敛到几个明确的写入函数里在应用层用互斥队列串行执行问题就再也没出现过。对查找为主的页面来说这个体量足够用没必要引入外部消息队列。这里整理一份常见问题速查表都是我实际遇到或听说过的典型情况。问题现象可能原因处理建议启动报原生模块缺失平台/Node 版本不匹配清缓存后重新安装依赖或执行 npm rebuild线上查询结果为空数据库路径用相对路径改用 process.cwd() 拼绝对路径写入报 database is locked多写并发或事务过长串行化写操作缩小事务范围LIKE 搜索越来越慢通配符开头索引失效改前缀匹配或引入全文搜索Serverless 平台写入失败文件系统只读或临时换持久化存储或自托管运行5.4 几个容易被忽略的隐藏坑最后再补几个零碎但很有效的经验。一是 Next.js 开发模式下数据库连接会被重复创建。开发服务器每次修改代码模块可能重新执行顶层代码如果不用全局缓存会产生多个数据库连接偶尔出现文件锁定。这个问题的表现很随机有时是偶发的查询失败有时是写入报错。全局单例模式在这里不仅是为了性能更是为了运行稳定。二是事务一定要短。better-sqlite3 的事务函数包裹的代码越少越好里面不要做外部网络请求、不要读大文件、不要 sleep。SQLite 的写锁只有在事务提交后才会释放事务越长别的写入者等待越久。三是不要忽视数据库文件的大小。SQLite 文件用久了会产生碎片膨胀到合理数据的数倍。偶尔执行一次VACUUM把文件压缩到实际大小对部署到磁盘空间受限的场合很有帮助。这个命令不会破坏数据但会锁库安排在低峰期跑。四是要注意备份。SQLite 只有一个文件备份方式非常朴素停写后复制文件或者用 SQLite 自身的在线备份 API。对查找类应用来说我会在启动脚本里加一个参数手动触发时把 app.db 复制走这个成本几乎为零关键时候能救命。这套组合我用了这么久最大的感受就是轻。技术上不复杂但每一步都能踩到实处连接用单例、查询用参数绑定、路径用绝对路径、索引要服务于真实查询。如果你手头也有一个中小数据量、以查找为主、希望快速交付的网页项目跟着上面的流程走一遍应该能少走不少弯路。最后再分享一个小扩展思路如果未来搜索需求升级成全文检索、需要匹配多个字段并按相关度排序可以在同一套架构里引入 SQLite 的 FTS5 扩展查询函数的数据层替换一下页面的其他部分完全不用动。这算是这套组合里性价比最高的一个升级方向了。