Automatyczne utworzenie bezpiecznego klieneta API Next.js na podstawie dokumentacji Swagger w NestJS
Dowiedz się, jak usunąć duplikowane typy API za pomocą NestJS Swagger i Orval, aby automatycznie generować bezpieczne pod względem typów hooki React Query dla Next.js.
Budowa aplikacji typu full-stack w TypeScript zazwyczaj rozpoczyna się od dużo powtarzającej się pracy.
Na backendzie w NestJS definiuje się typ żądania. Następnie ta sama struktura jest ponownie definiowana na frontendzie w Next.js. Tworzy się punkt końcowy kontrolera, a potem ręcznie tworzy się wywołanie fetch, aby do niego dotrzeć. Modyfikuje się odpowiedź API, a potem trzeba mieć nadzieję, że pamięta się wszystkie miejsca w klienta, które od niej zależą.
To działa dobrze na początkowym etapie.
Jednak w miarę rozszerzania się interfejsu API, powtarzające się typy oraz ręcznie napisana logika żądań stają się stałym źródłem błędów i straty czasu.
Bardziej zrównoważonym podejściem jest traktowanie umowy API backendu jako jedynego źródła prawdy.
Taki proces pracy opiera się na:
- NestJS
- Swagger
- Orval
- Next.js
- TanStack Query
Główna idea jest prosta:
NestJS endpoints + Swagger DTOs
→ OpenAPI document
→ Orval generation
→ TypeScript types, request functions, and React Query hooks
→ Next.js frontend
Zamiast ręcznie synchronizować typy frontendu i backendu, regenerujesz klienta bezpośrednio na podstawie umowy API za każdym razem, gdy dojdzie do zmian.
Pełny projekt użyty jako przykład jest dostępny w tym repozytorium: next-modern-stack na GitHubie.
Problem: Typy API się rozchodzą
Wyobraź sobie, że dodajesz funkcję „tworzenia notatek” do aplikacji notatnikowej w stylu terminala.
Typowa ręcznie napisana implementacja frontendu mogłaby wyglądać mniej więcej tak:
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();
}
Żaden z tych kodów nie jest z natury błędny.
Problem polega na tym, że teraz musisz ręcznie zarządzać całą serią elementów: formatem wysyłanej prośby, formatem otrzymywanej odpowiedzi, adresem URL do wywołania, używanym czasownikiem HTTP, sposobem prezentacji błędów, śledzeniem stanu ładowania, reprezentacją statusu mutacji oraz obsługą cache’owania i ponownego pobierania danych.
Załóżmy teraz, że zmieni się backend.
Być może folderId zostanie przemianowany. Być może odpowiedź otrzyma nowe pole. Być może ścieżka routingu się zmieni. Być może API zacznie zwracać zupełnie inny format danych.
Twój frontend może stracić synchronizację bez żadnego ostrzeżenia.
Rozwiązaniem jest przestanie traktowania frontendu i backendu jako dwóch niezależnych źródeł prawdy.
Czyń z Swagger kontrakt API
Swagger umożliwia twojemu API NestJS opisywanie własnych punktów końcowych, ciał zapytań oraz modeli odpowiedzi.
Dzięki tym metadaniom NestJS może wygenerować kompletny dokument OpenAPI.
Oto DTO służące do tworzenia notatki:
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;
}
To dokładnie określa, jaki kształt musi mieć ciało żądania.
Następnie dokumentuje się sam endpoint:
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,
);
}
}
Dwa szczegóły są tutaj bardzo ważne:
CreateNoteDtookreśla oczekiwane ciało żądania.NoteDtookreśla kształt udanej odpowiedzi.
Pole operationId również odgrywa kluczową rolę.
operationId: "createNote";
Przydzielane jest endpointowi stabilne, czytelne dla człowieka nazwisko w generowanym kliencie.
Dzięki temu frontend może później wywołać hook o nazwie:
useCreateNote();
zamiast czegoś niejasnego lub automatycznie utworzonego na podstawie surowej ścieżki routingu.
Publikowanie dokumentacji Swagger z NestJS
Gdy już skomentowaliśmy kontrolery i DTO, kolejnym krokiem jest podłączenie Swagger podczas uruchamiania aplikacji 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();
Gdy Twoja API działa lokalnie, Swagger udostępnia dwa istotne punkty końcowe:
http://localhost:3001/api-docs
http://localhost:3001/api-docs-json
Pierwsza adresa URL serwuje interaktywną środowisko Swagger UI, gdzie możesz ręcznie przeglądać i testować punkty końcowe.
Druga zwraca surowy dokument OpenAPI w formacie JSON, który jest dokładnie tym, co wykorzystuje Orval do budowy klienta frontendowego.
Generowanie klienta API Next.js za pomocą Orval
Zadaniem Orval jest odczytanie tego dokumentu OpenAPI i przekształcenie go w kod TypeScript, który aplikacja Next.js może bezpośrednio importować.
W tym ustawieniu plik konfiguracyjny Orval znajduje się wewnątrz samego projektu 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",
},
},
});
Ta konfiguracja mówi Orval, aby:
- pobierz JSON Swagger z działającego serwera NestJS
- napisz utworzony klient do pliku
src/generated/api.ts - stwórz haki TanStack Query razem z surowymi funkcjami
- korzystaj z wbudowanej API przeglądarki
fetchdo wysyłania żądań - kieruj te żądania do lokalnej instancji NestJS
Pakiet Next.js definiuje skrypt służący do uruchomienia procesu generowania:
{
"scripts": {
"generate": "orval --config orval.config.ts"
}
}
W korzeniu monorepo Turborepo rozpowszechnia to polecenie we wszystkich przestrzeniach roboczych, które go potrzebują:
{
"scripts": {
"generate": "turbo run generate"
}
}
Z korzenia repozytorium jedno polecenie regeneruje wszystko:
bun run generate
Pamiętaj, że twój serwer NestJS musi już działać, ponieważ Orval pobiera swój schemat z:
http://localhost:3001/api-docs-json
Co generuje Orval
Uruchomienie generatora tworzy plik podobny do tego:
apps/web/src/generated/api.ts
Traktuj ten plik jako wynik budowy, a nie kod źródłowy — nie edytuj go ręcznie.
Jeśli coś musi ulec zmianie, zaktualizuj backendowe DTO-y oraz adnotacje Swagger, a następnie uruchom ponownie proces generowania, aby stworzyć nowy klient.
Dzięki dobrze zdefiniowanemu kontraktowi API Orval może wygenerować:
- typy TypeScript dla zapytań i odpowiedzi
- funkcje zapytań w pełni typizowane
- hooki TanStack Query do pobierania danych
- hooki TanStack Query do wykonywania mutacji
- funkcje pomocnicze umożliwiające dostęp do kluczy zapytań w celu unieważnienia cache’u
Jako przykład, endpoint folderów jest adnotowany tym identyfikatorem operacji:
@ApiOperation({
summary: "Get all folders",
operationId: "getFolders",
})
Orval przekształca to w gotowy do użycia hook na stronie frontend:
useGetFolders();
wraz z odpowiadającą mu funkcją pomocniczą do obsługi kluczy zapytań:
getGetFoldersQueryKey();
Ponieważ identyfikator operacji jest wyraźnie zdefiniowany w backendzie, nazwy generowanych hooków i pomocników pozostają spójne i przewidywalne, zamiast być domyślane na podstawie ścieżki URL.
Używanie generowanych hooków w Next.js
Dzięki klientowi stworzonemu przez Orval, frontend w Next.js nie wymaga już ręcznego wywołania fetch dla każdego endpointu.
Rozważmy wzorzec używany w funkcji notatnika w terminalu:
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;
}
Przepływ działa w następujący sposób:
useGetFolders()pobiera aktualną listę folderów.useCreateNote()wysyła żądanie utworzenia notatki.- Gdy mutacja zostanie pomyślnie zakończona,
getGetFoldersQueryKey()wskazuje na wpis w pamięci podręcznej, który wymaga aktualizacji,- a TanStack Query automatycznie ponownie pobiera dane folderów.
W rezultacie interfejs odzwierciedla najnowszy stan serwera, bez konieczności ręcznego synchronizowania wzajemnie powiązanych elementów stanu React. To jeden z największych atutów łączenia generowanych hooki z mechanizmem zarządzania pamięcią cache w TanStack Query.
Używanie danych początkowych, gdy serwer je już posiada
W wielu konfiguracjach Next.js niektóre dane są już dostępne na serwerze, zanim składnik kliencki zostanie w ogóle załadowany.
Naprzимер, składnik terminala może otrzymać swoje foldery jako parametry i przekazać je do hooka jako dane początkowe:
const { data: foldersData } = useGetFolders({
query: {
initialData: {
data: initialFolders,
status: 200,
headers: new Headers(),
},
},
});
Takie podejście umożliwia natychmiastowe renderowanie strony przy użyciu danych już pobraanych po stronie serwera, podczas gdy TanStack Query nadal zajmuje się cache’owaniem oraz ewentualnym ponownym pobieraniem danych. Zachowujesz zalety warstwy do pobierania danych generowanej automatycznie, nie traćając przy tym pracy już wykonanej przez Next.js.
Proces pracy w przypadku zmiany API
Zawsze, gdy dodajesz lub modyfikujesz punkt końcowy, postępuj zgodnie z tą sekwencją:
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
Jako przykład załóżmy, że treść przesyłana do utworzenia notatki zmienia się z takiego kształtu:
{
text: string;
folderId: number;
}
na taki, z dodanym flagiem:
{
text: string;
folderId: number;
isPinned: boolean;
}
Należy odpowiednio zaktualizować DTO backendu:
@ApiSchema({ name: "CreateNote" })
export class CreateNoteDto {
@ApiProperty()
text: string;
@ApiProperty()
folderId: number; @ApiProperty()
isPinned: boolean;
}
Następnie należy ponownie wygenerować klienta:
bun run generate
Od tego momentu wywołanie createNote() w frontendzie wymaga pola isPinned, a TypeScript oznaczy każde miejsce wywołania, które nadal wymaga aktualizacji. Taka natychmiastowa informacja pochodząca od kompilatora jest znacznie bardziej niezawodna niż poleganie na własnej pamięci co do wszystkich miejsc w oddzielnej bazie kodu, gdzie typy wymagają korekty.
Dlaczego to jest lepsze niż pakiet typów współdzielonych
Częstym wzorcem w monorepo jest utworzenie dedykowanego pakietu, coś w rodzaju:
packages/
└── types/
Zarówno frontend, jak i backend importują wtedy te same interfejsy TypeScript z tego wspólnego miejsca.
To może działać dość dobrze w określonych sytuacjach.
Jednak rozwiązuje to tylko część problemu utrzymania synchronizacji API.
Po prostu dzielenie się interfejsami pozostawia wiele rzeczy niewyjaśnionych:
- punkty końcowe opisane w dokumentacji
- funkcje żądań zawierające odpowiednie typy
- hooki mutacji zawierające odpowiednie typy
- spójne klucze cache’u
- centralizowane ścieżki punktów końcowych
- spójne definicje metod HTTP
- referencję, którą mogą przeglądać inni programiści
- umowę, którą mogliby wykorzystać dodatkowi klienci
Zamiast tego połączenie Swagger i Orval zapewnia pipeline oparty na API.
Backend definiuje i jest właścicielem tej umowy.
Frontend po prostu wykorzystuje kod wygenerowany na podstawie tego kontraktu.
W rezultacie uzyskuje się znacznie klarowniejsze rozdzielenie między tymi dwoma aplikacjami.
Częste błędy, których należy unikać
Ręczna edycja plików wygenerowanych
Nigdy nie edytuj takiego pliku bezpośrednio ręcznie:
apps/web/src/generated/api.ts
Wszelkie zmiany, które tam wprowadzisz, zostaną usunięte przy następnej regeneracji klienta.
Zamiast tego popraw kontrakt na stronie backendu i ponownie wygeneruj klienta.
Pomijanie pola operationId
Jeśli nie zdefiniujesz identyfikatorów operacji, nazwy tras w kodzie wygenerowanym mogą stać się nieuporządkowane lub trudne do przewidzenia.
Zamiast tego przydziel jasne, opisowe identyfikatory, na przykład:
operationId: "getFolders";
operationId: "createNote";
operationId: "updateNote";
Takie rozwiązanie sprawia, że nazwy hooków na stronie frontendu są znacznie łatwiejsze do odczytania.
Zapominanie o ponownej regeneracji po zmianach na backendzie
Frontend nie ma sposobu, by dowiedzieć się o zmianie punktu końcowego, dopóki nie uruchomisz ponownie kroku generowania.
Traktuj regenerację jako standardową część swojego cyklu rozwoju, a nie coś dodanego na ostatnią chwilę.
Pisanie własnych funkcji fetch obok generowanych hooków
Zawsze używaj hooków, które Orval dla ciebie generuje.
Zużyj ręcznie napisaną funkcję fetch tylko wtedy, gdy napotkasz prawdziwą ograniczenie, którego nie może rozwiązać generowany klient.
w przeciwnym razie po prostu ponownie wprowadzasz tę samą logiczność zapytań, której próbowałeś się pozbyć.
Traktowanie generowanych typów jako walidacji w czasie wykonywania
Generowane typy TypeScript są przydatne do wykrywania błędów podczas pisania kodu.
Nie zapewniają żadnej ochrony przed nieznanymi lub błędnie sformatowanymi danymi przychodzącymi w czasie wykonywania.
Dla zadań takich jak wysyłanie formularzy, parametry URL, dane przekazywane przez webhooki czy informacje z usług third-party, połącz swoje typy z rzeczywistą weryfikacją w czasie wykonywania, używając narzędzi takich jak Zod.
Jeden kontrakt API, mniej powtarzalnej pracy
Największą zaletą łączenia Swagger i Orval jest nie tylko lepsza bezpieczeństwo typów.
Jest to możliwość uniknięcia konieczności podejmowania tych samych decyzji wielokrotnie.
Zamiast ręcznie budować warstwę API frontendu dla każdego endpointu, opisujesz kontrakt raz i pozwalasz, by powtarzalne, przewidywalne elementy były generowane automatycznie.
NestJS endpoint
→ Swagger contract
→ Orval generated client
→ TanStack Query hook
→ Next.js UI
Korzyści obejmują:
- mniej powtarzających się definicji typów
- mniej ręcznie pisanych funkcji żądania
- jasniejszą granicę pomiędzy frontendem a backendem
- natychmiastowe błędy TypeScript przy zmianie struktury API
- gotowe hooki do zapytań i modyfikacji
Taka konfiguracja sprawia również, że bezpieczniej jest pozwalać narzędziom AI na współpracę przy kodzie.
Gdy asystent AI dodaje nowy punkt końcowy backendu, można to zrealizować poprzez prostą sekwencję kroków:
Update the NestJS controller and DTOs
→ document the endpoint with Swagger
→ run bun run generate
→ use the generated hook in Next.js
→ run Biome
To o wiele bardziej niezawodny model niż proszenie asystenta AI o tworzenie i utrzymywanie rozproszonego, powtarzającego się kodu API w całym projekcie.
Budowanie pełnego procesu pracy
To rozwiązanie oparte na Swagger i Orval stanowi jedną z części szerszej, nowoczesnej konfiguracji TypeScript, która łączy Next.js z NestJS, wykorzystując narzędzia takie jak przestrzenie robocze Bun, Turborepo, PostgreSQL z Prismą, TanStack Query, nuqs, Biome i Lefthook, a także procesy pracy wspomagane przez AI oparte na ponownie używalnych regułach i umiejętnościach.
Pełny, działający przykład tej konfiguracji można zobaczyć tutaj:
Przeglądź repozytorium na GitHubie
Literatura pokrewna
- Tworzenie interfejsów użytkownika dla agentów AI wieloetapowych za pomocą Next.js i AI SDK — Dowiedz się, jak zaprojektować interfejs agenta AI gotowy do użycia w produkcji, wykorzystując narzędzia typowane, pętle wieloetapowe oraz komponenty interfejsu generatywnego w formacie strumieniowym w Next.js.
- Przekształcanie obsługujących trasy w Next.js w celową warstwę BFF — Dowiedz się, co rozwiązuje wzorzec Backend for Frontend, dlaczego pojawia się on ponownie w aplikacjach Next.js oraz jak uniknąć przekształcania obsługujących tras w rozbudowane „bóstwa” programistyczne.