Skip to content

Latest commit

 

History

History
407 lines (283 loc) · 17.2 KB

File metadata and controls

407 lines (283 loc) · 17.2 KB

← 返回首页

第二章 - API Route:在 Next.js 里写后端

第一章的博客只能渲染静态数据。真实的应用需要一个后端——接受客户端的请求、操作数据库、返回结果。这一章介绍 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 即可发送。

目录

  1. 为什么要在 Next.js 里写后端
  2. Route Handler 基本写法
  3. 数据库层的设计
  4. schema.ts:数据形状的唯一真相
  5. client.ts:数据库客户端
  6. blogs.ts:集中管理增删改查
  7. GET /api/blogs:列表与过滤
  8. POST /api/blogs:Zod 验证入参
  9. GET /api/blogs/[id]:单条查询与 404
  10. PUT /api/blogs/[id]:局部更新
  11. DELETE /api/blogs/[id]:删除与 204
  12. HTTP 状态码备查

1. 为什么要在 Next.js 里写后端

先说结论:在 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 的基本用法和背后的设计思路。


2. 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.tsxroute.ts 不能并存于同一目录page.tsx 负责渲染 HTML,route.ts 负责处理 API 请求。一个目录只能是其中一种。


3. 数据库层的设计

为什么用 Drizzle ORM,而不是手写 SQL?

直接用 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/ 里的文件,所有路由不用动。


4. schema.ts:数据形状的唯一真相

// 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 类型 BlogNewBlog 由 Drizzle 从 schema 自动推断,不需要手写:

  • Blog:查询时返回的完整行(含 id
  • NewBlog:插入时需要提供的字段(不含 id,因为它是自增的)

这样做的好处是不存在两份定义。如果你给表加一个 tags 字段,只需改 schema.ts 一处,Blog 类型会自动更新,所有用到它的地方会立刻出现 TypeScript 错误提示——编译器帮你找到所有需要同步修改的位置。


5. client.ts:数据库客户端

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 的麻烦。生产环境里种子数据通常由独立的脚本管理,这里为了简单直接写在客户端里。


6. blogs.ts:集中管理增删改查

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 = ?

注意函数的返回类型都是 BlogBlog | undefined——这些类型来自 schema.ts 的自动推断,TypeScript 全程知道每个查询的结果长什么样。


7. GET /api/blogs:列表与过滤

// 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.httpGET all blogs / GET blogs filtered by author


8. POST /api/blogs:Zod 验证入参

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.successfalseresult.error.issues 包含所有不合格的字段和原因,可以直接返回给客户端。校验通过后,result.data 的类型被 TypeScript 推断为 z.infer<typeof CreateBlogSchema>,后续代码可以安全使用。

这个模式有一个名字:在信任边界处验证。API 的入口就是边界——进来之前不信任,校验通过之后才信任。

测试:见 api_test.httpPOST - create a new blog / POST - missing required fields / POST - empty title


9. GET /api/blogs/[id]:单条查询与 404

// 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,需要 awaitRouteContext<"/api/blogs/[id]"> 是 Next.js 16 提供的全局类型助手,不需要 import。

Number(id) 把字符串转成数字,因为 URL 参数永远是字符串。

测试:见 api_test.httpGET a single blog / GET a blog that does not exist


10. PUT /api/blogs/[id]:局部更新

// 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.httpPUT - update a blog / PUT - update a blog that does not exist / PUT - empty title


11. DELETE /api/blogs/[id]:删除与 204

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 返回 booleantrue 表示确实删除了一行,false 表示没有找到匹配的行(因此没有删除任何东西)——此时应该返回 404 而不是 200。

测试:见 api_test.httpDELETE a blog / DELETE a blog that does not exist


12. HTTP 状态码备查

本章用到的状态码,以及它们的含义:

状态码 名称 含义
200 OK 请求成功,有响应体
201 Created 资源创建成功,通常在 POST 后返回
204 No Content 请求成功,无响应体(用于 DELETE)
400 Bad Request 客户端发来的数据有问题(格式错误、缺少字段)
404 Not Found 请求的资源不存在
500 Internal Server Error 服务器内部出错(Next.js 默认处理,无需手动返回)

规律:2xx 表示成功,4xx 表示客户端的错,5xx 表示服务器的错。