Головна / Статті / Автоматично створіть безпечного за типами клієнта API для Next.js з NestJS Swagger.

Автоматично створіть безпечного за типами клієнта API для Next.js з NestJS Swagger.

Дізнайтеся, як усунути дубльовані типи API за допомогою NestJS Swagger та Orval для автоматичного створення безпечних за типами гаків React Query для Next.js.

2462 слів

Створення повноцінного застосунку на 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 дозволяє вашому 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();

замість чогось нечіткого або автоматично отриманого з первинного шляху руті.

Опублікування документації 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 на те, що потрібно зробити:

  • завантажити Swagger JSON з працюючого сервера NestJS
  • записати створений клієнт у src/generated/api.ts
  • створити гачки TanStack Query разом із первинними функціями
  • використовувати вбудовану API браузера fetch для запитів
  • направити ці запити на локальну інстанцію NestJS

Пакет Next.js визначає скрипт для запуску генерації:

{
  "scripts": {
    "generate": "orval --config orval.config.ts"
  }
}

У корені monorepo 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 для виконання змін
  • допоміжні функції, які надають ключі запитів для анулювання кешу

Як приклад, ендпоїнт folder позначений цим ідентифікатором операції:

@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;
}

Алгоритм роботи виглядає так:

  1. useGetFolders() отримує список поточних папок.
  2. useCreateNote() надсилає запит на створення нотатки.
  3. Як тільки мутація успішно виконується,
  4. getGetFoldersQueryKey() вказує на запис у кеші, який потрібно оновити,
  5. а 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-в першу чергу.

Бекенд визначає та контролює цей контракт.

Фронтенд просто використовує код, створений на основі цього контракту.

У результаті досягається набагато чіткіше розділення між двома додатками.

Поширені помилки, яких слід уникати

Ручна edycja створених файлів

Ніколи не редагуйте такий файл безпосередньо вручну:

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
  • готові хуки для запитів та змін
  • простіше скасування кешу
  • документація 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

    Пов’язана література

  • Три шаблони TypeScript, які покращують архітектуру React-додатків — Дізнайтеся, як шаблони Repository, Observer та Builder використовують систему типів TypeScript для створення більш чистих та легкозберіганих кодових баз для React та Next.js.
  • Уроки з розробки SaaS на Next.js та створення інструменту scaffolding CLI — Пояснюються основні рішення щодо вибору технологій, автентифікації, мульти-термінатності, обліку та керування станом, а також процес створення CLI для спрощення налаштування проєктів Next.js.