Автоматическое создание типобезопасного клиента API Next.js на основе Swagger в NestJS
Узнайте, как устранить дублирование типов API с помощью NestJS Swagger и Orval для автоматического генерирования безопасных с точки зрения типов хуков React Query для Next.js.
Разработка полноценного приложения на 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 каждый раз, когда он меняется.
Полный проект, используемый в качестве примера, доступен в этом репозитории: next-modern-stack на GitHub.
Проблема: различия в типах 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 позволяет вашему API NestJS описывать собственные концовки, тела запросов и модели ответов.
На основе этих метаданных 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();
вместо чего-то неопределенного или автоматически полученного из исходного пути маршрута.
Публикация документации Swagger из NestJS
После аннотирования контроллеров и DTO, следующим шагом является настройка Swagger при запуске приложения NestJS.
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, где можно вручную просматривать и тестировать концы сети.
Второй возвращает необработанный документ OpenAPI в формате JSON, и именно его использует Orval для создания клиента фронтенда.
Создание API-клиента Next.js с помощью Orval
Задача Orval — прочитать этот документ OpenAPI и преобразовать его в код на TypeScript, который ваше приложение Next.js может напрямую импортировать.
В данной конфигурации файл настроек 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 на следующее:
- загружаем JSON-файл Swagger с работающего сервера NestJS
- записываем сгенерированный клиент в файл
src/generated/api.ts - создаем хуки TanStack Query вместе с базовыми функциями
- используем встроенный API браузера
fetchдля отправки запросов - направляем эти запросы на локальную инстанцу 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 backend и аннотации Swagger, затем снова запустите генерацию для создания клиента заново.
При наличии четко определенного контракта API Orval может выдать:
- типы TypeScript для запросов и ответов
- функции запросов с полным типированием
- хуки TanStack Query для получения данных
- хуки TanStack Query для выполнения мутаций
- помощные функции, предоставляющие ключи запросов для аннулирования кэша
В качестве примера конечная точка folders помечена этим идентификатором операции:
@ApiOperation({
summary: "Get all folders",
operationId: "getFolders",
})
Orval преобразует её в готовый к использованию хук на фронтенде:
useGetFolders();
а также в соответствующую помощную функцию для ключей запросов:
getGetFoldersQueryKey();
Поскольку идентификатор операции определяется явно на бэкенде, имена генерируемых хуков и вспомогательных функций остаются последовательными и предсказуемыми, вместо того чтобы определяться по пути 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
Преимущества включают:
- меньше дублирующихся определений типов
- меньше функций запросов, написанных вручную
- более четкое разделение между фронтендом и бэкендом
- немедленные ошибки TypeScript при изменении структуры API
- готовые хуки для запросов и изменений данных
Такая конфигурация также делает более безопасным использование инструментов ИИ для улучшения кодбазы.
Когда ИИ-ассистент добавляет новый бэкенд-эндпоинт, его можно направить через простую последовательность действий:
Update the NestJS controller and DTOs
→ document the endpoint with Swagger
→ run bun run generate
→ use the generated hook in Next.js
→ run Biome
Это гораздо надежнее, чем просить ИИ-ассистента создавать и поддерживать разрозненный, дублирующийся код API по всему проекту.
Создание полного рабочего процесса
Эта схема на основе Swagger и Orval — лишь часть более широкой современной конфигурации TypeScript, сочетающей Next.js и NestJS с использованием таких инструментов, как рабочие пространства Bun, Turborepo, PostgreSQL с Prisma, TanStack Query, nuqs, Biome и Lefthook, а также рабочих процессов с поддержкой ИИ, построенных на переиспользуемых правилах и навыках.
Полный рабочий пример этой конфигурации можно посмотреть здесь:
Посмотреть репозиторий на GitHub
Связанная литература
- Создание интерфейсов для многоэтапных AI-агентов с использованием Next.js и AI SDK — Узнайте, как спроектировать готовый к использованию интерфейс AI-агента с помощью типизированных инструментов, многоэтапных циклов и компонентов генеративного интерфейса в Next.js.
- Преобразование обработчиков маршрутов Next.js в специализированный слой BFF — Узнайте, что решает паттерн Backend for Frontend, почему он снова используется в приложениях Next.js и как избежать превращения обработчиков маршрутов в тяжелые «боговские» объекты.