Startseite / Artikel / Automatisch einen typsicheren Next.js API-Client aus NestJS Swagger erzeugen

Automatisch einen typsicheren Next.js API-Client aus NestJS Swagger erzeugen

Erfahren Sie, wie Sie doppelte API-Typen beseitigen können, indem Sie NestJS Swagger und Orval nutzen, um type-sichere React Query-Hooks für Next.js automatisch zu generieren.

2462 Wörter

Der Aufbau einer vollstackbasierten TypeScript-Anwendung beginnt in der Regel mit viel wiederholter Arbeit.

Man definiert einen Anfrage-Typ im NestJS-Backend. Anschließend wird derselbe Aufbau auch im Next.js-Frontend neu definiert. Es wird ein Controller-Endpunkt erstellt, und anschließend wird manuell eine fetch-Anfrage zum Aufrufen dieses Endpunkts geschrieben. Eine API-Antwort wird angepasst, wobei man hofft, sich an alle Stellen im Client zu erinnern, die von ihr abhängen.

Das funktioniert in den Anfangsphasen gut.

Doch je größer die API-Oberfläche wird, desto mehr führen duplizierte Typen sowie manuell geschriebene Anfrage-Logik zu ständigen Fehlern und Zeitverlusten.

Ein nachhaltigerer Ansatz besteht darin, den API-Vertrag des Backends als einzige Quelle der Wahrheit zu betrachten.

Dieser Workflow setzt voraus:

  • NestJS
  • Swagger
  • Orval
  • Next.js
  • TanStack Query

Die Kernidee ist einfach:

NestJS endpoints + Swagger DTOs
→ OpenAPI document
→ Orval generation
→ TypeScript types, request functions, and React Query hooks
→ Next.js frontend

Anstatt die Typen von Frontend und Backend manuell abzustimmen, regeneriert man den Client direkt aus dem API-Vertrag, sobald sich dieser ändert.

Das vollständige Projekt, das als Beispiel verwendet wird, ist in diesem Repository verfügbar: next-modern-stack auf GitHub.

Das Problem: API-Typen entwickeln sich auseinander

Stellen Sie sich vor, Sie fügen einer Notiz-App im Terminal-Stil eine „Notiz erstellen“-Funktion hinzu.

Eine typische manuell geschriebene Frontend-Implementierung könnte so aussehen:

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();
}

Kein Teil dieses Codes ist an sich falsch.

Das Problem ist, dass Sie nun manuell für die Wartung einer ganzen Reihe von Aspekten verantwortlich sind: die Struktur der ausgehenden Anfrage, die Struktur der eingehenden Antwort, welche URL aufgerufen werden soll, welches HTTP-Verb verwendet werden muss, wie Fehler angezeigt werden, wie der Ladezustand überwacht wird, wie der Status einer Änderung dargestellt wird sowie wie Caching und Neuladen gehandhabt werden.

Nehmen wir nun an, sich ändert die Backend-Struktur.

Möglicherweise wird folderId umbenannt. Vielleicht erhält die Antwort ein neues Feld. Möglicherweise ändert sich der Route-Pfad. Vielleicht beginnt die API, eine völlig andere Struktur zurückzugeben.

Ihr Frontend kann ohne jegliche Warnung aus dem Takt geraten.

Die Lösung besteht darin, Frontend und Backend nicht länger als zwei unabhängige Quellen der Wahrheit zu betrachten.

Machen Sie Swagger zum API-Vertrag

Swagger ermöglicht es Ihrer NestJS-API, ihre eigenen Endpunkte, Anfragekörper und Antwortmodelle zu beschreiben.

Durch diese Metadaten kann NestJS ein vollständiges OpenAPI-Dokument erzeugen.

Hier ist ein DTO zur Erstellung einer Notiz:

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

Dies definiert genau, welche Form der Anfragenkörper haben muss.

Danach dokumentieren Sie den Endpunkt selbst:

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,
    );
  }
}

Zwei Aspekte sind hier besonders wichtig:

  • CreateNoteDto definiert den erwarteten Anfragenkörper.
  • NoteDto definiert die Struktur einer erfolgreichen Antwort.

Auch das Feld operationId spielt eine entscheidende Rolle.

operationId: "createNote";

Es weist dem Endpunkt einen stabilen, für Menschen lesbaren Namen innerhalb des generierten Clients zu.

Dadurch kann das Frontend später einen Hook mit diesem Namen aufrufen:

useCreateNote();

anstelle von etwas Unbestimmtem oder Automatisch abgeleitetem aus dem rohen Route-Pfad.

Swagger-Dokumentation über NestJS veröffentlichen

Sobald Ihre Controller und DTOs annotiert sind, ist der nächste Schritt das Einbinden von Swagger beim Start der NestJS-Anwendung.

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();

Sobald Ihre API lokal läuft, stellt Swagger zwei Endpunkte bereit, die man kennen sollte:

http://localhost:3001/api-docs
http://localhost:3001/api-docs-json

Die erste URL liefert die interaktive Swagger UI, in der Sie die Endpunkte manuell durchsuchen und testen können.

Die zweite gibt das rohe OpenAPI-Dokument in JSON-Form zurück – genau dieses wird von Orval verwendet, um Ihren Frontend-Klienten zu erstellen.

Nächster.js API-Klient mit Orval generieren

Orvals Aufgabe besteht darin, dieses OpenAPI-Dokument zu lesen und es in TypeScript-Code umzuwandeln, den Ihre Next.js-Anwendung direkt importieren kann.

In dieser Konfiguration befindet sich die Orval-Konfigurationsdatei innerhalb des Next.js-Projekts selbst:

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",
    },
  },
});

Diese Konfiguration weist Orval an:

  • ziehen Sie das Swagger JSON vom laufenden NestJS-Server ab
  • schreiben Sie den generierten Client in src/generated/api.ts
  • erstellen Sie TanStack Query-Hooks zusammen mit den Rohfunktionen
  • basiert man sich für Anfragen auf die eingebettete Browser-fetch-API
  • richtet man diese Anfragen auf die lokale NestJS-Instanz aus

Das Next.js-Paket definiert ein Skript, um die Generierung auszulösen:

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

Am Wurzelverzeichnis des Monorepos verteilt Turborepo diesen Befehl auf alle Arbeitsumgebungen, die ihn benötigen:

{
  "scripts": {
    "generate": "turbo run generate"
  }
}

Vom Repository-Root aus regeneriert ein einziger Befehl alles:

bun run generate

Denken Sie daran, dass Ihr NestJS-Server zuvor laufen muss, da Orval sein Schema daraus abruft:

http://localhost:3001/api-docs-json

Was Orval generiert

Durch Ausführen des Generators entsteht eine Datei, die dieser ähnelt:

apps/web/src/generated/api.ts

Betrachten Sie diese Datei als Build-Ausgabe und nicht als Quellcode – bearbeiten Sie sie nicht manuell.

Falls etwas geändert werden muss, aktualisieren Sie die Backend-DTOs sowie die Swagger-Anmerkungen und führen Sie anschließend erneut die Generierung aus, um den Client neu zu erstellen.

Bei einem gut definierten API-Vertrag kann Orval Folgendes ausgeben:

  • TypeScript-Typen für Anfragen und Antworten
  • vollständig typisierte Anfragefunktionen
  • TanStack Query-Hooks zum Abrufen von Daten
  • TanStack Query-Hooks für Mutationen
  • Hilfsfunktionen, die Abfrageschlüssel zur Cache-Invalidierung bereitstellen

Als Beispiel ist das Endpunkt-Folder mit dieser Operation-ID annotiert:

@ApiOperation({
  summary: "Get all folders",
  operationId: "getFolders",
})

Orval wandelt dies in einen sofort einsetzbaren Hook für die Frontend-Seite um:

useGetFolders();

zusammen mit einem entsprechenden Hilfsfunktion für Abfrageschlüssel:

getGetFoldersQueryKey();

Weil die Operation-ID explizit auf der Backend-Seite definiert wird, bleiben die generierten Namen für Hooks und Hilfsfunktionen konsistent und vorhersehbar, anstatt aus dem URL-Pfad abgeschlossen werden zu müssen.

Generierte Hooks in Next.js verwenden

Mit dem von Orval generierten Client benötigt Ihre Next.js-Frontend-Plattform keine manuell geschriebenen fetch-Aufrufe mehr für jeden Endpunkt.

Betrachten Sie das Muster, das in einer Terminal-Notizfunktion verwendet wird:

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

Der Ablauf funktioniert wie folgt:

  1. useGetFolders() holt die aktuelle Liste der Ordner ab.
  2. useCreateNote() sendet den Anfrage, um eine Notiz zu erstellen.
  3. Sobald die Mutation erfolgreich abgeschlossen ist,
  4. getGetFoldersQueryKey() weist auf den Cache-Eintrag hin, der aktualisiert werden muss,
  5. und TanStack Query lädt die Ordnerdaten automatisch erneut herunter.

Dadurch spiegelt die Benutzeroberfläche den neuesten Serverzustand wider, ohne dass Sie manuell verschachtelte React-Zustände synchronisieren müssen. Dies ist einer der größten Vorteile der Kombination von generierten Hooks mit der Cacheverwaltung von TanStack Query.

Verwenden Sie Anfangsdaten, wenn der Server sie bereits hat

In vielen Next.js-Einrichtungen sind bereits vor dem Laden einer Client-Komponente einige Daten auf dem Server verfügbar.

Zum Beispiel kann die Terminal-Komponente ihre Ordner als Props erhalten und diese als Anfangsdaten an den Hook weitergeben:

const { data: foldersData } = useGetFolders({
  query: {
    initialData: {
      data: initialFolders,
      status: 200,
      headers: new Headers(),
    },
  },
});

Dadurch kann die Seite sofort mit den bereits vom Server heruntergeladenen Daten gerendert werden, während TanStack Query weiterhin für das Caching und eventuelle erneute Abfragen zuständig ist. Sie behalten die Vorteile der generierten Datenabruflage, ohne die Arbeit zu ignorieren, die Next.js bereits für Sie erledigt hat.

Der Arbeitsablauf, wenn sich Ihre API ändert

Immer wenn Sie einen Endpunkt hinzufügen oder ändern, befolgen Sie diese Abfolge:

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

Als Beispiel nehmen wir an, dass sich die Payload zur Erstellung einer Notiz von dieser Form:

{
  text: string;
  folderId: number;
}

in diese umwandelt, wobei ein Flag hinzugefügt wird:

{
  text: string;
  folderId: number;
  isPinned: boolean;
}

Dann aktualisieren Sie den Backend-DTO entsprechend:

@ApiSchema({ name: "CreateNote" })
export class CreateNoteDto {
  @ApiProperty()
  text: string;
  @ApiProperty()
  folderId: number;  @ApiProperty()
  isPinned: boolean;
}

Anschließend erzeugen Sie den Client neu:

bun run generate

Von diesem Zeitpunkt an erfordert der Aufruf von createNote() im Frontend den Parameter isPinned, und TypeScript markiert alle Aufrufstellen, die noch aktualisiert werden müssen. Solche sofortigen, vom Compiler gesteuerten Rückmeldungen sind weitaus zuverlässiger als das Vertrauen darauf, dass man sich an alle Stellen erinnert, an denen in einer separaten Codebasis ein manuell gepflegter Typ angepasst werden muss.

Warum das besser ist als ein gemeinsames Types-Paket

Ein gängiges Muster in Monorepos besteht darin, ein spezielles Paket einzurichten, wie zum Beispiel:

packages/
└── types/

Sowohl die Frontend- als auch die Backend-Anwendung importieren anschließend dieselben TypeScript-Schnittstellen aus dieser gemeinsamen Stelle.

In bestimmten Situationen kann dies recht gut funktionieren.

Allerdings behebt es nur einen Teil der Herausforderung, eine API im Einklang zu halten.

Das einfache Teilen von Schnittstellen lässt mehrere Aspekte unberücksichtigt:

  • in der Dokumentation beschriebene Endpunkte
  • Anfruffunktionen mit korrekten Typen
  • Mutation-Hooks mit korrekten Typen
  • konsistente Cache-Schlüssel
  • zentralisierte Endpunktpfade
  • konsistente Definitionen von HTTP-Methoden
  • eine Referenz, die andere Entwickler durchsehen können
  • einen Vertrag, den weitere Clients nutzen können

Die Kombination aus Swagger und Orval bietet hingegen einen API-first-Pipeline-Ansatz.

Das Backend definiert und ist für den Vertrag verantwortlich.

Die Frontend-Anwendung nutzt einfach den aus diesem Vertrag generierten Code.

Dadurch entsteht eine viel klarere Trennung zwischen den beiden Anwendungen.

Häufige Fehler, die vermieden werden sollten

Manuelles Bearbeiten von generierten Dateien

Bearbeiten Sie niemals eine solche Datei direkt per Hand:

apps/web/src/generated/api.ts

Jegliche Änderungen, die Sie dort vornehmen, werden beim nächsten Neugenerieren des Clients überschrieben.

Besser ist es, den Vertrag im Backend zu korrigieren und anschließend den Client neu zu generieren.

Auslassen von operationId

Falls Sie Operation-ID-Werte nicht definieren, können die Pfadnamen im generierten Code unübersichtlich oder unvorhersehbar werden.

Weisen Sie stattdessen klare, beschreibende IDs zu, wie zum Beispiel:

operationId: "getFolders";
operationId: "createNote";
operationId: "updateNote";

Dadurch entstehen im Frontend viel lesbarere Namen für die Hooks.

Vergessen, nach Änderungen im Backend neu zu generieren

Die Frontend-Plattform hat keine Möglichkeit, zu erkennen, dass sich ein Endpunkt geändert hat, es sei denn, Sie führen den Generierungsprozess erneut aus.

Betrachten Sie die Regeneration als fester Bestandteil Ihres Entwicklungszyklus und nicht als nachträgliche Maßnahme.

Erstellung eigener fetch-Funktionen neben den generierten Hooks

Verwenden Sie standardmäßig die von Orval für Sie generierten Hooks.

wenden Sie nur eine selbstgeschriebene fetch-Funktion an, wenn Sie auf echte Einschränkungen stoßen, mit denen der generierte Client nicht umgehen kann.

Andernfalls führen Sie lediglich dieselbe doppelte Anfragenlogik ein, die Sie eigentlich beseitigen wollten.

Betrachtung der generierten Typen als Laufzeitvalidierung

Die generierten TypeScript-Typen sind nützlich, um Fehler beim Schreiben von Code frühzeitig zu erkennen.

Sie bieten jedoch keinen Schutz vor unbekannten oder fehlerhaften Eingaben, die zur Laufzeit eintreffen.

Für Dinge wie Formularabsendungen, URL-Parameter, webhook-Beladungen oder Daten von Drittanbieterdiensten sollten Sie Ihre Typen mit einer tatsächlichen Laufzeitvalidierung verknüpfen, beispielsweise mithilfe von Zod.

Ein API-Vertrag, weniger wiederholende Arbeit

Der größte Vorteil der Kombination von Swagger und Orval liegt nicht nur in einer verbesserten Typsicherheit.

Es bedeutet, dass Sie nicht mehr immer wieder dieselben Entscheidungen treffen müssen.

Anstatt für jeden einzelnen Endpunkt manuell eine Frontend-API-Schicht neu zu erstellen, beschreiben Sie den Vertrag einmal und lassen die wiederholten, vorhersehbaren Teile automatisch generieren.

NestJS endpoint
→ Swagger contract
→ Orval generated client
→ TanStack Query hook
→ Next.js UI

Die Vorteile sind:

  • weniger doppelte Typdefinitionen
  • weniger manuell geschriebene Anfruffunktionen
  • eine klarere Trennung zwischen Frontend und Backend
  • sofortige TypeScript-Fehler, wenn sich die API-Struktur ändert
  • fertige Abfrages- und Mutationshooks
  • einfachere Cache-Invalidierung
  • API-Dokumentation, auf die das gesamte Team zugreifen kann
  • Diese Konfiguration macht es außerdem sicherer, KI-Tools zur Erweiterung des Codebases einzusetzen.

    Wenn ein KI-Assistent einen neuen Backend-Endpunkt hinzufügt, können Sie dies über eine einfache Abfolge steuern:

    Update the NestJS controller and DTOs
    → document the endpoint with Swagger
    → run bun run generate
    → use the generated hook in Next.js
    → run Biome
    

    Das ist ein weitaus zuverlässigeres Muster als die Anforderung an einen KI-Assistenten, über das Projekt verteiltes, dupliziertes API-Code zu erstellen und zu warten.

    Der vollständige Workflow aufbauen

    Dieser Pipeline mit Swagger und Orval ist nur ein Teil einer umfassenderen modernen TypeScript-Konfiguration, die Next.js und NestJS miteinander verbindet und dabei Tools wie Bun Workspaces, Turborepo, PostgreSQL mit Prisma, TanStack Query, nuqs, Biome und Lefthook sowie KI-gestützte Workflows nutzt, die auf wiederverwendbaren Regeln und Fähigkeiten basieren.

    Ein vollständiges, funktionierendes Beispiel für diese Konfiguration können Sie hier einsehen:

    Das GitHub-Repositorium ansehen

    Zusätzliche Literatur

  • Drei TypeScript-Muster, die die Architektur von React-Anwendungen verbessern — Erfahren Sie, wie die Repository-, Observer- und Builder-Muster das Typensystem von TypeScript nutzen, um sauberere, wartbarere Codebasen für React und Next.js zu erstellen.
  • Lektionen aus dem Veröffentlichen eines Next.js SaaS und dem Erstellen einer Scaffoldings-CLI — Es werden die grundlegenden Entscheidungen erläutert – Wahl der Technologiestacke, Authentifizierung, Multi-Tenancy, Abrechnung und Zustandsverwaltung – sowie der Aufbau einer CLI, die die Einrichtung von Next.js-Projekten vereinfacht.