根据 NestJS Swagger 自动生成类型安全的 Next.js API 客户端
了解如何利用 NestJS Swagger 和 Orval 自动为 Next.js 生成类型安全的 React Query 钩子,从而消除重复的 API 类型。
构建全栈 TypeScript 应用程序通常始于大量的重复性工作。
你首先在 NestJS 后端定义请求类型,然后在 Next.js 前端重新定义相同的结构。你需要创建控制器接口,再手动编写 fetch 请求来调用它。修改 API 响应后,还得确保记住了客户端中所有依赖该响应的部分。
在项目初期,这种方式还能正常运行。
但随着 API 功能的扩展,重复的类型定义以及手动编写的请求逻辑会不断引发错误并浪费时间。
更可持续的做法是将后端的 API 规范视为唯一的真实数据来源。
这种工作流程依赖于:
- NestJS
- Swagger
- Orval
- Next.js
- TanStack Query
其核心理念非常简单:
NestJS endpoints + Swagger DTOs
→ OpenAPI document
→ Orval generation
→ TypeScript types, request functions, and React Query hooks
→ Next.js frontend
无需手动同步前端与后端的类型,只需在 API 合同发生变化时直接从该合同重新生成客户端代码即可。
用作示例的完整项目可在此仓库中找到:GitHub 上的 next-modern-stack。
问题:API 类型出现偏差
想象一下,你正在为一个终端风格的记事本应用添加“创建笔记”的功能。
典型的手动编写的前端实现可能如下所示:
type CreateNoteInput = {
text: string;
folderId: number;
};
type Note = {
id: number;
text: string;
folderId: number;
createdAt: string;
};export async function createNote(input: CreateNoteInput): Promise<Note> {
const response = await fetch("http://localhost:3001/notes", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(input),
}); if (!response.ok) {
throw new Error("Could not create note");
} return response.json();
}
这些代码本身并没有什么错误。
问题在于现在你必须手动维护一大堆内容:出站请求的格式、入站响应的格式、应调用哪个URL、使用哪种HTTP动词、如何展示错误信息、如何跟踪加载状态、如何表示变更的状态,以及如何处理缓存和重新获取数据。
现在假设后端发生了变化。
也许folderId被重命名了;也许响应中新增了字段;也许路由路径发生了变化;也许API开始返回完全不同的格式。
你的前端类型可能会在毫无预警的情况下出现不一致。
解决办法是不要再将前端和后端视为两个独立的真实数据源。
让Swagger成为API契约
Swagger允许你的NestJS API描述自身的端点、请求体以及响应模型。
通过这些元数据,NestJS 可以生成完整的 OpenAPI 文档。
以下是用于创建便签的 DTO:
import { ApiProperty, ApiSchema } from "@nestjs/swagger";
@ApiSchema({ name: "CreateNote" })
export class CreateNoteDto {
@ApiProperty({
description: "The text content of the note",
})
text: string; @ApiProperty({
description: "The ID of the folder this note belongs to",
})
folderId: number;
}
这明确了请求体必须遵循的格式。
接下来,需要为接口本身编写文档:
import { Body, Controller, Post } from "@nestjs/common";
import { ApiOperation, ApiResponse } from "@nestjs/swagger";
import { CreateNoteDto } from "./create-note.dto";
import { NoteDto } from "./note.dto";
import { NotesService } from "./notes.service";
@Controller("notes")
export class NotesController {
constructor(private readonly notesService: NotesService) {} @Post()
@ApiOperation({
summary: "Create a note",
operationId: "createNote",
})
@ApiResponse({
status: 201,
description: "The note has been successfully created.",
type: NoteDto,
})
create(@Body() createNoteDto: CreateNoteDto) {
return this.notesService.create(
createNoteDto.text,
createNoteDto.folderId,
);
}
}
这里有两个非常重要的细节:
CreateNoteDto定义了预期的请求体格式。NoteDto定义了成功响应的格式。
operationId 字段也起着关键作用。
operationId: "createNote";
它在生成的客户端中为该接口分配一个稳定且易于理解的名称。
这样前端就可以调用名为该名称的钩子函数:
useCreateNote();
而无需使用那些模糊的或从原始路由路径自动生成的名称。
从 NestJS 发布 Swagger 文档
在为控制器和DTO添加了注解之后,下一步就是在NestJS应用启动时配置Swagger。
import { NestFactory } from "@nestjs/core";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.create(AppModule); app.enableCors({
origin: "http://localhost:3000",
}); const config = new DocumentBuilder()
.setTitle("Next Modern Stack API")
.setDescription("API documentation for Next Modern Stack")
.setVersion("1.0")
.build(); const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup("api-docs", app, document); await app.listen(process.env.PORT ?? 3001);
}bootstrap();
一旦API在本地运行,Swagger会提供两个值得了解的端点:
http://localhost:3001/api-docs
http://localhost:3001/api-docs-json
第一个URL提供交互式的Swagger UI,你可以在其中手动浏览和测试各个端点。
第二个URL以JSON格式返回原始的OpenAPI文档,而这正是Orval用来构建前端客户端的依据。
使用Orval生成Next.js API客户端
Orval的任务是读取该OpenAPI文档,并将其转换为Next.js应用可以直接导入的TypeScript代码。
在这种配置下,Orval的配置文件位于Next.js项目内部:
import { defineConfig } from "orval";
export default defineConfig({
api: {
input: "http://localhost:3001/api-docs-json",
output: {
target: "./src/generated/api.ts",
client: "react-query",
httpClient: "fetch",
baseUrl: "http://localhost:3001",
},
},
});
此配置告诉Orval要执行以下操作:
- 从正在运行的 NestJS 服务器获取 Swagger JSON
- 将生成的客户端代码写入
src/generated/api.ts - 在原始函数之外生成 TanStack Query 钩子
- 使用浏览器的原生
fetchAPI 发送请求 - 将这些请求指向本地的 NestJS 实例
Next.js 包定义了一个用于触发生成的脚本:
{
"scripts": {
"generate": "orval --config orval.config.ts"
}
}
在单仓库根目录下,Turborepo 会将该命令传播到所有需要它的工作区:
{
"scripts": {
"generate": "turbo run generate"
}
}
从仓库根目录出发,一个命令即可重新生成所有内容:
bun run generate
请记住,NestJS 服务器必须先处于运行状态,因为 Orval 是从该服务器获取架构信息的:
http://localhost:3001/api-docs-json
Orval 生成的内容
运行生成器后会生成类似这样的文件:
apps/web/src/generated/api.ts
请将此文件视为构建输出而非源代码——切勿手动编辑它。
如果需要修改内容,请更新后端的 DTO 和 Swagger 注解,然后重新运行生成流程以创建新的客户端代码。
在拥有明确定义的 API 合同的前提下,Orval 可以生成:
- 用于请求和响应的 TypeScript 类型
- 完全类型化的请求函数
- 用于获取数据的 TanStack Query 钩子
- 用于执行数据变更的 TanStack Query 钩子
- 用于暴露查询键以实现缓存失效的辅助函数
例如,folders 接口端点就标注了这样的操作 ID:
@ApiOperation({
summary: "Get all folders",
operationId: "getFolders",
})
Orval 会将其转换为前端可直接使用的钩子:
useGetFolders();
同时还提供对应的查询键辅助函数:
getGetFoldersQueryKey();
由于操作ID是在后端明确定义的,因此生成的钩子与辅助函数的名称具有一致性且可预测,无需从URL路径中猜测。
在Next.js中使用生成的钩子
借助Orval生成的客户端,您的Next.js前端不再需要为每个接口手动编写fetch调用。
以终端记事本功能所采用的模式为例:
import { useQueryClient } from "@tanstack/react-query";
import {
getGetFoldersQueryKey,
useCreateNote,
useGetFolders,
} from "@/generated/api";
export function TerminalContent() {
const queryClient = useQueryClient(); const { data: foldersData } = useGetFolders(); const { mutateAsync: createNote } = useCreateNote(); async function handleCreateNote(text: string, folderId: number) {
await createNote({
data: {
text,
folderId,
},
}); await queryClient.invalidateQueries({
queryKey: getGetFoldersQueryKey(),
});
} return null;
}
其工作流程如下:
useGetFolders()用于获取当前文件夹列表。useCreateNote()负责发送创建笔记的请求。- 一旦该操作成功完成,
getGetFoldersQueryKey()会指向需要更新的缓存条目,- TanStack Query便会自动重新获取文件夹数据。
因此,无需手动同步 React 的嵌套状态,界面就能实时反映服务器的最新状态。这正是将生成的钩子与 TanStack Query 的缓存管理功能结合使用的最大优势之一。
当服务器已拥有数据时使用初始数据
在许多 Next.js 配置中,客户端组件加载之前,服务器上就已经有一些数据可用。
例如,终端组件可能会以属性的形式接收文件夹信息,并将其作为初始数据传递给钩子:
const { data: foldersData } = useGetFolders({
query: {
initialData: {
data: initialFolders,
status: 200,
headers: new Headers(),
},
},
});
通过这种方式,可以利用已在服务器端获取的数据立即渲染页面,而 TanStack Query 会继续负责缓存管理及后续的重新获取操作。这样既能保留自动生成的数据获取层的优势,又不会浪费 Next.js 已经为你完成的工作。
API 发生变化时的工作流程
每当添加或修改端点时,请遵循以下步骤:
1. Update the NestJS controller or service
2. Update Swagger DTOs and endpoint metadata
3. Start the API locally
4. Run bun run generate
5. Review the generated API client changes
6. Update frontend usage where needed
7. Run bun run lint:fix
8. Let TypeScript show you any remaining mismatches
例如,假设创建笔记的负载数据从这种形式:
{
text: string;
folderId: number;
}
变为添加了标志后的这种形式:
{
text: string;
folderId: number;
isPinned: boolean;
}
你需要相应地更新后端DTO:
@ApiSchema({ name: "CreateNote" })
export class CreateNoteDto {
@ApiProperty()
text: string;
@ApiProperty()
folderId: number; @ApiProperty()
isPinned: boolean;
}
然后重新生成客户端代码:
bun run generate
从那以后,前端调用createNote()时就需要传入isPinned参数,TypeScript会标记出所有仍需更新的调用位置。这种由编译器提供的即时反馈,远比依赖自己记住在独立代码库中需要调整的每个类型位置要可靠得多。
为何这比共享类型包更好
在单仓库架构中,常见的做法是创建一个专用包,类似如下所示:
packages/
└── types/
前端和后端都会从该共享位置导入相同的 TypeScript 接口。
在某些情况下,这种方式能起到不错的作用。
然而,它仅解决了保持 API 同步问题的一部分。
仅仅共享接口还缺少许多内容:
- 文档中描述的端点
- 带有正确类型的请求函数
- 带有正确类型的变更钩子
- 一致的缓存键
- 集中式的端点路径
- 一致的 HTTP 方法定义
- 供其他开发者查阅的参考资料
- 可供其他客户端使用的契约
而 Swagger 与 Orval 的结合则能为你提供以 API 为优先的流程。
后端负责定义并拥有该契约。
前端仅负责使用该合约生成的代码。
这样一来,两个应用之间的分离会更加清晰。
需避免的常见错误
手动编辑生成的文件
绝不要直接手动编辑此类文件:
apps/web/src/generated/api.ts
您所做的任何更改在下次重新生成客户端时都会被清除。
应改为在后端修正合约,然后再重新生成客户端。
忽略operationId
如果未定义操作标识符,生成代码中的路由名称可能会显得杂乱无章或难以预测。
应使用清晰、具有描述性的标识符,例如:
operationId: "getFolders";
operationId: "createNote";
operationId: "updateNote";
这样能让前端的钩子名称更易理解。
后端修改后忘记重新生成
在重新运行生成步骤之前,前端无法得知端点已发生变化。
应将重新生成视为开发流程中的常规环节,而非事后才考虑的事。
在生成的钩子之外编写自定义 fetch 函数
优先使用 Orval 为您生成的钩子。
只有当遇到生成的客户端无法处理的真正限制时,才考虑使用手写的 fetch 函数。
否则只会重新引入你原本试图消除的重复请求逻辑。
将生成的类型视为运行时验证手段
生成的 TypeScript 类型有助于在编写代码时发现错误。
它们无法防范运行时传入的未知或格式错误的输入。
对于表单提交、URL参数、webhook负载或第三方服务提供的数据,应使用Zod之类的工具将类型定义与实际运行时验证相结合。
统一的API契约,减少重复工作
将Swagger与Orval结合使用的最大优势不仅在于提升了类型安全性。
更重要的是,无需反复做出相同的决策。
不必为每个接口端点都手动重建前端API层,只需一次描述契约,让那些重复且可预测的部分自动生成即可。
NestJS endpoint
→ Swagger contract
→ Orval generated client
→ TanStack Query hook
→ Next.js UI
其好处包括:
- 更少的重复类型定义
- 更少手动编写的请求函数
- 前端与后端之间的界限更加清晰
- API结构发生变化时能立即触发TypeScript错误提示
- 现成的查询和修改操作钩子函数
这样的架构还能让 AI 工具更安全地参与代码库的构建。
当 AI 助手添加新的后端接口时,只需通过一系列简单的步骤即可完成:
Update the NestJS controller and DTOs
→ document the endpoint with Swagger
→ run bun run generate
→ use the generated hook in Next.js
→ run Biome
这比让 AI 助手在项目中分散地创建并维护重复的 API 代码要可靠得多。
构建完整的工作流
这个基于 Swagger 和 Orval 的流程只是更广泛的现代 TypeScript 架构中的一小部分,该架构将 Next.js 与 NestJS 相结合,同时运用 Bun workspaces、Turborepo、带 Prisma 的 PostgreSQL、TanStack Query、nuqs、Biome 和 Lefthook 等工具,以及基于可复用规则和技能构建的 AI 辅助工作流。
您可以在这里查看该架构的完整可运行示例:
相关阅读
- 使用 Next.js 和 AI SDK 构建多步骤 AI 智能体界面 — 了解如何运用类型化工具、多步骤循环以及 Next.js 中的流式生成 UI 组件来设计可用于生产环境的 AI 智能体界面。
- 将 Next.js 路由处理程序转化为专门的 BFF 层 — 了解前端后端模式能解决什么问题,为何它在 Next.js 应用中再度流行,以及如何避免让路由处理程序变成臃肿的“上帝对象”。