第一章的博客只能渲染静态数据。真实的应用需要一个后端——接受客户端的请求、操作数据库、返回结果。这一章介绍 Next.js 的 Route Handler,它让你在同一个项目里同时写前端和后端,共用一套代码,一次部署。
示例代码:codes
运行方式:
cd codes
npm install
npm run dev打开 http://localhost:3000 查看 API 端点列表。
测试 API:用 VS Code 的 REST Client 扩展打开同级目录下的 api_test.http,点击每个请求上方的 Send Request 即可发送。
- 为什么要在 Next.js 里写后端
- Route Handler 基本写法
- 数据库层的设计
- schema.ts:数据形状的唯一真相
- client.ts:数据库客户端
- blogs.ts:集中管理增删改查
- GET /api/blogs:列表与过滤
- POST /api/blogs:Zod 验证入参
- GET /api/blogs/[id]:单条查询与 404
- PUT /api/blogs/[id]:局部更新
- DELETE /api/blogs/[id]:删除与 204
- HTTP 状态码备查
先说结论:在 Next.js 里写后端不是大型项目的最佳实践。如果你的团队有独立的后端服务,或者 API 需求比较复杂,推荐继续用 Express、Fastify 这类专业的后端框架——它们有完整的中间件链、插件生态、WebSocket 支持、以及更成熟的认证方案(Passport.js、fastify-jwt 等)。Next.js 的 Route Handler 功能相对基础,稍微复杂的需求就容易碰到天花板。
那什么时候适合用 Next.js 写后端?
当 API 需求足够简单,Next.js 的"前后端共址"(co-location)会带来明显的便利:
- 无需 CORS 配置:前端和 API 部署在同一个域名,浏览器不会拦截跨域请求。
- 共享类型:前后端 import 同一个 TypeScript 类型文件,不用手写两份接口定义。
- 一次部署:一个
npm run build,一个部署命令,没有两个服务互相依赖的运维成本。 - 基础认证够用:登录、JWT 验证、Cookie 管理,配合 Auth.js(原 NextAuth)可以覆盖大多数常见场景。
这种模式通常叫 BFF(Backend For Frontend)——Next.js 只作为前端的数据代理层,处理简单的增删改查和认证,复杂的业务逻辑仍然在独立后端完成。
本章演示的正是这个场景:一个简单的博客 CRUD API,用来理解 Route Handler 的基本用法和背后的设计思路。
Route Handler 放在 app/ 目录下,文件名必须是 route.ts(不是 page.tsx)。在同一个文件里,你可以 export 多个函数,函数名就是 HTTP 方法:
app/
api/
blogs/
route.ts ← 处理 /api/blogs 的 GET 和 POST
[id]/
route.ts ← 处理 /api/blogs/[id] 的 GET 和 DELETE
// app/api/blogs/route.ts
export async function GET(request: NextRequest) {
return Response.json({ message: "hello" });
}
export async function POST(request: NextRequest) {
const body = await request.json();
return Response.json(body, { status: 201 });
}Response.json() 是 Web 标准 API——不是 Next.js 专属的,在任何现代 JS 运行时里都能用。第二个参数传 { status: 201 } 可以指定状态码。
page.tsx和route.ts不能并存于同一目录。page.tsx负责渲染 HTML,route.ts负责处理 API 请求。一个目录只能是其中一种。
直接用 better-sqlite3 查询数据库完全可行,写起来像这样:
const blog = sqlite.prepare("SELECT * FROM blogs WHERE id = ?").get(id);但这有两个问题。第一,返回值的类型是 unknown——你需要手动写一个接口告诉 TypeScript 这个对象长什么样,而且没有任何机制保证这个接口和数据库的实际结构一致,两者可能悄悄偏离。第二,当查询变复杂(多条件、JOIN、分页),拼接 SQL 字符串容易出错,且难以复用。
Drizzle ORM 的核心价值只有一条:让 TypeScript 知道数据库里有什么。你在 schema.ts 里描述表结构,Drizzle 就能从中推断出查询结果的类型,不需要手写任何接口。查询写法是链式 API,TypeScript 全程跟踪类型,写错字段名或传错类型编译器会立刻报错。
Drizzle 刻意做得很薄——它不隐藏 SQL,生成的 SQL 完全可预测,出了问题你知道数据库里发生了什么。这和 Prisma 等"重 ORM"的设计哲学不同,Drizzle 更接近"带类型的 SQL 构建器"。
本章新增了一个 db/ 目录,负责所有和数据库打交道的代码:
db/
schema.ts ← 表结构定义,TypeScript 类型的来源
client.ts ← 数据库连接,模块级单例
blogs.ts ← 查询函数,供 route.ts 调用
为什么要把数据库代码单独放到一个目录,而不是直接写在 route.ts 里?
路由文件只管 HTTP 细节:读请求、写响应、设状态码。如果把 SQL 查询也塞进去,一个文件就要同时关心"这个 id 是不是有效请求?"和"Drizzle 的 .get() 返回什么类型?"——关注点太多。
查询函数可以复用:findBlogById 可能同时被 GET /api/blogs/[id] 和未来的 GET /api/search 调用。写在 db/blogs.ts 里,两个路由都能 import,不用复制粘贴。
换数据库只需改 db/ 目录:今天用 SQLite,明天换 PostgreSQL,只需要改 db/ 里的文件,所有路由不用动。
// db/schema.ts
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const blogs = sqliteTable("blogs", {
id: integer("id").primaryKey({ autoIncrement: true }),
title: text("title").notNull(),
content: text("content").notNull(),
author: text("author").notNull(),
date: text("date").notNull(),
});
export type Blog = typeof blogs.$inferSelect;
export type NewBlog = typeof blogs.$inferInsert;schema.ts 是整个系统里唯一需要描述数据形状的地方。TypeScript 类型 Blog 和 NewBlog 由 Drizzle 从 schema 自动推断,不需要手写:
Blog:查询时返回的完整行(含id)NewBlog:插入时需要提供的字段(不含id,因为它是自增的)
这样做的好处是不存在两份定义。如果你给表加一个 tags 字段,只需改 schema.ts 一处,Blog 类型会自动更新,所有用到它的地方会立刻出现 TypeScript 错误提示——编译器帮你找到所有需要同步修改的位置。
client.ts 是数据库的入口,它做三件事:连接数据库、建表、写入初始数据。db/blogs.ts 只需要 import 它导出的 db,就能对数据库做任何操作。
// db/client.ts
import Database from "better-sqlite3";
import { drizzle } from "drizzle-orm/better-sqlite3";
import path from "path";
// 打开(或创建)SQLite 数据库文件
const sqlite = new Database(path.join(process.cwd(), "data/blogs.db"));
// 如果表不存在就建表
sqlite.exec(`
CREATE TABLE IF NOT EXISTS blogs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
author TEXT NOT NULL,
date TEXT NOT NULL
)
`);
// 如果表是空的就写入种子数据,方便开发时直接有内容可看
const count = sqlite.prepare("SELECT COUNT(*) as count FROM blogs").get() as { count: number };
if (count.count === 0) {
// ... 插入初始数据
}
// 导出 Drizzle 客户端,供 blogs.ts import
export const db = drizzle({ client: sqlite });CREATE TABLE IF NOT EXISTS 的作用是:数据库文件第一次创建时自动建表,之后每次启动服务器这条语句什么都不做。这样不需要手动运行建表脚本,npm run dev 就够了。
种子数据(seed)是开发阶段的便利设计——数据库为空时自动填入几条示例数据,省去每次测试都要手动 POST 的麻烦。生产环境里种子数据通常由独立的脚本管理,这里为了简单直接写在客户端里。
db/blogs.ts 把这张表所有可能用到的数据库操作都集中在一处:查全部、按 id 查、插入、更新、删除。这不是为了"让路由文件短一点",而是把数据操作的逻辑收拢到同一个地方统一维护。
好处是:当多个 route 需要同一个操作时,直接 import 这里的函数,不会出现同一段查询逻辑散落在三个文件里的情况。当查询逻辑需要调整(比如加排序、加分页),只需改这一处,所有调用方自动受益。
// db/blogs.ts
import { eq } from "drizzle-orm";
import { db } from "./client";
import { blogs, type Blog, type NewBlog } from "./schema";
export type UpdateBlog = Partial<NewBlog>;
export function findAllBlogs(author?: string): Blog[] {
if (author) {
return db.select().from(blogs).where(eq(blogs.author, author)).all();
}
return db.select().from(blogs).all();
}
export function findBlogById(id: number): Blog | undefined {
return db.select().from(blogs).where(eq(blogs.id, id)).get();
}
export function createBlog(data: NewBlog): Blog {
return db.insert(blogs).values(data).returning().get();
}
export function updateBlog(id: number, data: UpdateBlog): Blog | undefined {
return db.update(blogs).set(data).where(eq(blogs.id, id)).returning().get();
}
export function deleteBlog(id: number): boolean {
const deleted = db.delete(blogs).where(eq(blogs.id, id)).returning().all();
return deleted.length > 0;
}几个 Drizzle 查询模式值得记住:
| 方法 | 含义 |
|---|---|
.all() |
返回所有匹配行,结果是数组 |
.get() |
返回第一条匹配行,没有则返回 undefined |
.returning() |
插入/删除/更新后把受影响的行返回出来 |
eq(blogs.id, id) |
等于条件,类型安全,相当于 WHERE id = ? |
注意函数的返回类型都是 Blog 或 Blog | undefined——这些类型来自 schema.ts 的自动推断,TypeScript 全程知道每个查询的结果长什么样。
// app/api/blogs/route.ts
export async function GET(request: NextRequest) {
const author = request.nextUrl.searchParams.get("author") ?? undefined;
const data = findAllBlogs(author);
return Response.json(data);
}request.nextUrl.searchParams 是 Next.js 封装的 URL 参数读取工具。get("author") 在没有该参数时返回 null,?? undefined 把它转成 undefined,这样 findAllBlogs 里的 if (author) 判断才能正确工作。
测试:见 api_test.http — GET all blogs / GET blogs filtered by author。
const CreateBlogSchema = z.object({
title: z.string().min(1),
content: z.string().min(1),
author: z.string().min(1),
date: z.string().min(1),
});
export async function POST(request: NextRequest) {
const body = await request.json();
const result = CreateBlogSchema.safeParse(body);
if (!result.success) {
return Response.json({ error: result.error.issues }, { status: 400 });
}
const blog = createBlog(result.data);
return Response.json(blog, { status: 201 });
}为什么需要 Zod,TypeScript 不够吗?
TypeScript 的类型检查只在编译时存在。服务器运行起来后,await request.json() 拿到的是一个普通 JavaScript 对象,类型信息已经消失——任何人都可以向你的 API 发送任何内容。
Zod 的 safeParse 在运行时验证数据,如果校验失败,result.success 为 false,result.error.issues 包含所有不合格的字段和原因,可以直接返回给客户端。校验通过后,result.data 的类型被 TypeScript 推断为 z.infer<typeof CreateBlogSchema>,后续代码可以安全使用。
这个模式有一个名字:在信任边界处验证。API 的入口就是边界——进来之前不信任,校验通过之后才信任。
测试:见 api_test.http — POST - create a new blog / POST - missing required fields / POST - empty title。
// app/api/blogs/[id]/route.ts
export async function GET(_req: Request, ctx: RouteContext<"/api/blogs/[id]">) {
const { id } = await ctx.params;
const blog = findBlogById(Number(id));
if (!blog) {
return Response.json({ error: "Blog not found" }, { status: 404 });
}
return Response.json(blog);
}动态路由的参数通过 ctx.params 获取,和 page.tsx 里的 params 一样,也是 Promise,需要 await。RouteContext<"/api/blogs/[id]"> 是 Next.js 16 提供的全局类型助手,不需要 import。
Number(id) 把字符串转成数字,因为 URL 参数永远是字符串。
测试:见 api_test.http — GET a single blog / GET a blog that does not exist。
// db/blogs.ts
export type UpdateBlog = Partial<NewBlog>;
export function updateBlog(id: number, data: UpdateBlog): Blog | undefined {
return db.update(blogs).set(data).where(eq(blogs.id, id)).returning().get();
}// app/api/blogs/[id]/route.ts
const UpdateBlogSchema = z.object({
title: z.string().min(1).optional(),
content: z.string().min(1).optional(),
author: z.string().min(1).optional(),
date: z.string().min(1).optional(),
});
export async function PUT(req: Request, ctx: RouteContext<"/api/blogs/[id]">) {
const { id } = await ctx.params;
const body = await req.json();
const result = UpdateBlogSchema.safeParse(body);
if (!result.success) {
return Response.json({ error: result.error.issues }, { status: 400 });
}
const blog = updateBlog(Number(id), result.data);
if (!blog) {
return Response.json({ error: "Blog not found" }, { status: 404 });
}
return Response.json(blog);
}Partial<NewBlog> 是什么?
NewBlog 是插入时必须提供所有字段的类型。Partial<T> 是 TypeScript 内置工具类型,把所有字段变成可选——更新时不需要把整篇博客重新发一遍,只传需要改的字段就够了。
为什么用 PUT 而不是 PATCH?
严格定义上,PUT 是全量替换,PATCH 是局部更新。这里我们的实现是局部更新(字段全部可选),用 PATCH 语义更准确。实际项目里你会看到两种写法都有,很多团队干脆统一用 PUT 图省事。这里用 PUT 只是为了让案例简单一些。
测试:见 api_test.http — PUT - update a blog / PUT - update a blog that does not exist / PUT - empty title。
export async function DELETE(_req: Request, ctx: RouteContext<"/api/blogs/[id]">) {
const { id } = await ctx.params;
const deleted = deleteBlog(Number(id));
if (!deleted) {
return Response.json({ error: "Blog not found" }, { status: 404 });
}
return new Response(null, { status: 204 });
}删除成功返回 204 No Content——没有 body,只有状态码。new Response(null, { status: 204 }) 是原生 Web API 的写法,Response.json() 会自动加 Content-Type: application/json header,但 204 响应不应该有 body,所以这里用 new Response(null, ...) 更准确。
deleteBlog 返回 boolean:true 表示确实删除了一行,false 表示没有找到匹配的行(因此没有删除任何东西)——此时应该返回 404 而不是 200。
测试:见 api_test.http — DELETE a blog / DELETE a blog that does not exist。
本章用到的状态码,以及它们的含义:
| 状态码 | 名称 | 含义 |
|---|---|---|
| 200 | OK | 请求成功,有响应体 |
| 201 | Created | 资源创建成功,通常在 POST 后返回 |
| 204 | No Content | 请求成功,无响应体(用于 DELETE) |
| 400 | Bad Request | 客户端发来的数据有问题(格式错误、缺少字段) |
| 404 | Not Found | 请求的资源不存在 |
| 500 | Internal Server Error | 服务器内部出错(Next.js 默认处理,无需手动返回) |
规律:2xx 表示成功,4xx 表示客户端的错,5xx 表示服务器的错。