Accueil / Articles / Générer automatiquement un client API Next.js sécurisé par type à partir de NestJS Swagger

Générer automatiquement un client API Next.js sécurisé par type à partir de NestJS Swagger

Apprenez à éliminer les types API dupliqués en utilisant NestJS Swagger et Orval pour générer automatiquement des hooks React Query sécurisés par rapport aux types, destinés à Next.js.

2462 mots

La création d’une application full-stack en TypeScript commence généralement par beaucoup d’efforts répétitifs.

Vous définez un type de requête dans votre backend NestJS, puis vous redéfinissez la même structure dans votre frontend Next.js. Vous créez un point de terminaison de contrôleur, puis vous écrivez manuellement une appel fetch pour y accéder. Vous modifiez une réponse API, puis vous espérez avoir mémorisé tous les endroits du client qui dépendent d’elle.

Cela fonctionne bien au début.

Mais à mesure que l’API s’étend, les types dupliqués et la logique de requête écrite manuellement deviennent une source constante d’erreurs et de perte de temps.

Une approche plus durable consiste à considérer le contrat API du backend comme la source unique de vérité.

Cette méthode repose sur :

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

L’idée de base est simple :

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

Au lieu de synchroniser manuellement les types du frontend et du backend, vous régénérez le client directement à partir du contrat API chaque fois qu’il change.

Le projet complet utilisé comme exemple est disponible dans ce répertoire : next-modern-stack sur GitHub.

Le problème : les types API s’éloignent l’un de l’autre

Imaginez que vous ajoutez une fonctionnalité « créer une note » à une application de carnet de notes au style terminal.

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

Aucun de ces codes n’est intrinsèquement incorrect.

Le problème est que vous devez désormais gérer manuellement toute une série d’aspects : la structure de la requête envoyée, celle de la réponse reçue, l’URL à appeler, le verbe HTTP à utiliser, la manière dont les erreurs sont affichées, le suivi de l’état de chargement, la représentation du statut des mutations, ainsi que le traitement du cache et des nouvelles requêtes.

Supposons maintenant que le backend change.

Peut-être que folderId est renommé. Peut-être que la réponse intègre un nouveau champ. Peut-être que l’URL du route change. Peut-être que l’API commence à retourner une structure complètement différente.

Votre interface frontend peut alors devenir décalée sans aucun avertissement.

La solution consiste à cesser de considérer le frontend et le backend comme deux sources de vérité indépendantes.

Faire de Swagger le contrat API

Swagger permet à votre API NestJS de décrire ses propres points d’entrée, les corps des requêtes et les modèles de réponse.

D’après ces métadonnées, NestJS peut générer un document OpenAPI complet.

Voici un DTO pour créer une note :

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

Ce dernier définit précisément la structure que doit avoir le corps de la requête.

Ensuite, on documente l’endpoint lui-même :

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

Deux détails sont particulièrement importants ici :

  • CreateNoteDto définit le corps de requête attendu.
  • NoteDto définit la structure d’une réponse réussie.

Le champ operationId joue également un rôle clé.

operationId: "createNote";

Il attribue à l’endpoint un nom stable et lisible par les humains au sein du client généré.

C’est ce qui permet à l’interface utilisateur d’appeler ultérieurement un crochet nommé :

useCreateNote();

plutôt que quelque chose de vague ou dérivé automatiquement du chemin d’URL brut.

Publier la documentation Swagger depuis NestJS

Avec vos contrôleurs et DTO annotés, l’étape suivante consiste à configurer Swagger lors du démarrage de l’application 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();

Lorsque votre API est en cours d’exécution localement, Swagger expose deux points de terminaison importants à connaître :

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

Le premier URL affiche l’interface interactive de Swagger, où vous pouvez naviguer et tester manuellement les points de terminaison.

Le second renvoie le document OpenAPI brut au format JSON, et c’est précisément ce que consomme Orval pour créer votre client frontend.

Générer le client API Next.js avec Orval

La tâche d’Orval est de lire ce document OpenAPI et de le transformer en code TypeScript que votre application Next.js peut importer directement.

Dans cette configuration, le fichier de configuration d’Orval se trouve à l’intérieur du projet Next.js lui-même :

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

Cette configuration indique à Orval de :

  • extraire le JSON Swagger depuis le serveur NestJS en cours d’exécution
  • écrire le client généré dans src/generated/api.ts
  • créer des hooks TanStack Query en plus des fonctions brutes
  • utiliser l’API fetch native du navigateur pour les requêtes
  • diriger ces requêtes vers l’instance locale de NestJS

Le paquet Next.js définit une commande pour déclencher la génération :

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

À la racine du monorepo, Turborepo diffuse cette commande dans chaque espace de travail qui en a besoin :

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

Depuis la racine du répertoire, une seule commande permet de tout regénérer :

bun run generate

N’oubliez pas que votre serveur NestJS doit être en cours d’exécution au préalable, car Orval récupère son schéma depuis :

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

Ce que génère Orval

L’exécution du générateur produit un fichier similaire à celui-ci :

apps/web/src/generated/api.ts

Traitez ce fichier comme un résultat de compilation, et non comme du code source — ne le modifiez pas manuellement.

Si des changements sont nécessaires, mettez à jour les DTOs backend et les annotations Swagger, puis relancez la génération pour recréer le client.

Avec un contrat API bien défini, Orval peut générer :

  • des types TypeScript pour les requêtes et les réponses
  • des fonctions de requête entièrement typées
  • des hooks TanStack Query pour la récupération de données
  • des hooks TanStack Query pour les mutations
  • des fonctions d’aide qui exposent des clés de requête pour l’invalidation du cache

Par exemple, l’endpoint des dossiers est annoté avec cet ID d’opération :

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

Orval le transforme en un hook prêt à l’emploi côté frontend :

useGetFolders();

ainsi qu’en une fonction d’aide correspondante pour les clés de requête :

getGetFoldersQueryKey();

Puisque l’ID de l’opération est défini explicitement sur le backend, les noms des hooks et des outils générés restent cohérents et prévisibles, au lieu d’être déduits à partir du chemin URL.

Utiliser les hooks générés dans Next.js

Avec le client généré par Orval, votre interface frontend Next.js n’a plus besoin d’une appel fetch manuel pour chaque point de terminaison.

Considérez le schéma utilisé dans une fonction de bloc-notes en terminal :

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

Le flux fonctionne comme suit :

  1. useGetFolders() récupère la liste des dossiers actuels.
  2. useCreateNote() envoie la demande pour créer une note.
  3. Lorsque la mutation est résolue avec succès,
  4. getGetFoldersQueryKey() indique l’entrée du cache à mettre à jour,
  5. et TanStack Query rafraîchit automatiquement les données des dossiers.

Ainsi, l’interface reflète l’état le plus récent du serveur sans que vous ayez à synchroniser manuellement les éléments imbriqués de l’état React. C’est l’un des avantages majeurs de l’utilisation combinée des hooks générés et de la gestion des caches de TanStack Query.

Utiliser les données initiales lorsque le serveur les possède déjà

Dans de nombreuses configurations Next.js, certaines données sont déjà disponibles sur le serveur avant même que le composant client ne soit chargé.

Par exemple, le composant terminal peut recevoir ses dossiers en tant que propriétés et les transmettre au hook en tant que données initiales :

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

Cela permet à la page de s’afficher instantanément en utilisant les données déjà récupérées côté serveur, tandis que TanStack Query gère le cache ainsi que toute nouvelle récupération ultérieure. Vous conservez les avantages de la couche de récupération de données générée sans perdre le travail déjà effectué par Next.js pour vous.

Le flux de travail lorsque votre API change

Chaque fois que vous ajoutez ou modifiez un point de terminaison, suivez cette séquence :

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

Par exemple, supposons que le payload nécessaire pour créer une note change de cette forme :

{
  text: string;
  folderId: number;
}

à celle-ci, avec l’ajout d’un indicateur :

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

Vous devrez alors mettre à jour le DTO backend en conséquence :

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

Ensuite, régénérez le client :

bun run generate

À partir de ce moment, l’appel à createNote() sur le frontend nécessite la présence de isPinned, et TypeScript signalera tous les endroits où des mises à jour sont encore nécessaires. Ce type de retour d’information immédiat, généré par le compilateur, est bien plus fiable que de compter sur sa propre mémoire pour se rappeler chaque endroit où un type géré manuellement doit être ajusté dans une base de code distincte.

Pourquoi c’est mieux qu’un package de types partagés

Un schéma courant dans les monorepos consiste à créer un package dédié, quelque chose comme :

packages/
└── types/

Tant le frontend que le backend importent alors les mêmes interfaces TypeScript depuis cette localisation partagée.

Cela peut fonctionner assez bien dans certaines situations.

Cependant, cela ne résout qu’une partie du défi consistant à maintenir une API synchronisée.

Le simple partage d’interfaces laisse de nombreuses choses en dehors du cadre :

  • les points d’entrée décrits dans la documentation
  • les fonctions de requête qui utilisent des types appropriés
  • les hooks de mutation qui utilisent des types appropriés
  • des clés de cache cohérentes
  • des chemins de points d’entrée centralisés
  • des définitions de méthodes HTTP cohérentes
  • une référence que d’autres développeurs peuvent consulter
  • un contrat que d’autres clients pourraient utiliser

La combinaison de Swagger et Orval vous offre plutôt un pipeline axé sur l’API.

Le backend définit et gère ce contrat.

Le frontend se contente d’utiliser le code généré à partir de ce contrat.

Cela permet une séparation bien plus claire entre les deux applications.

Erreurs courantes à éviter

Modification manuelle des fichiers générés

N’éditez jamais directement un fichier de cette sorte :

apps/web/src/generated/api.ts

Toutes les modifications que vous y apporterez seront effacées la prochaine fois que le client sera régénéré.

Préférez corriger le contrat côté backend avant de régénérer le client.

Omission du operationId

Si vous laissez les identifiants d’opération non définis, les noms des routes dans le code généré peuvent devenir confus ou imprévisibles.

Préférez plutôt utiliser des identifiants clairs et descriptifs, tels que :

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

Cela permet d’obtenir des noms de fonctions beaucoup plus lisibles du côté frontend.

Oubli de régénérer après des modifications côté backend

Le front-end ne peut pas savoir qu’un point de terminaison a changé tant que vous ne relancez pas l’étape de génération.

Considérez la régénération comme une partie intégrante de votre cycle de développement, et non comme une mesure prise en dernier recours.

Rédaction de fonctions fetch personnalisées à côté des hooks générés

Préférez utiliser les hooks que Orval génère pour vous.

N’utilisez une fonction fetch écrite manuellement que lorsque vous rencontrez une véritable limitation que le client généré ne peut pas gérer.

Sinon, vous réintroduisez simplement la même logique de demande redondante que vous essayiez d’éliminer.

Utilisation des types générés comme validation en temps de exécution

Les types TypeScript générés sont utiles pour détecter les erreurs pendant l’écriture du code.

Ils ne fournissent aucune protection contre des entrées inconnues ou mal formatées reçues en temps de exécution.

Pour des éléments tels que les soumissions de formulaires, les paramètres URL, les charges utiles webhook ou les données provenant de services tiers, associez vos types à une validation en temps de exécution réelle, en utilisant un outil comme Zod.

Un contrat API unique, moins de travail répétitif

Le plus grand avantage de combiner Swagger et Orval n’est pas seulement une meilleure sécurité des types.

C’est que vous n’avez plus besoin de prendre les mêmes décisions encore et encore.

Au lieu de reconstruire manuellement une couche API frontend pour chaque endpoint, vous décrivez le contrat une seule fois et laissez les parties répétitives et prévisibles être générées automatiquement.

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

Les avantages incluent :

  • moins de définitions de types redondantes
  • moins de fonctions de requête écrites manuellement
  • une frontière plus claire entre le frontend et le backend
  • des erreurs TypeScript immédiates lorsque la structure de l’API change
  • des hooks de requête et de mutation prêts à l’emploi
  • invalidation du cache simplifiée
  • documentation API à laquelle toute l’équipe peut se référer
  • Cette configuration rend également plus sûr le fait de permettre aux outils d’IA de contribuer au codebase.

    Lorsqu’un assistant IA ajoute un nouveau point de terminaison backend, vous pouvez le diriger via une séquence simple :

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

    C’est un modèle bien plus fiable que de demander à un assistant IA d’inventer et de gérer du code API dispersé et dupliqué dans le projet.

    Construire l’ensemble du flux de travail

    Cette chaîne Swagger-and-Orval n’est qu’une partie d’une configuration moderne TypeScript plus large qui associe Next.js et NestJS, en utilisant des outils tels que les espaces de travail Bun, Turborepo, PostgreSQL avec Prisma, TanStack Query, nuqs, Biome et Lefthook, ainsi que des flux de travail assistés par l’IA basés sur des règles et des compétences réutilisables.

    Voir le répertoire GitHub

    Lectures complémentaires

  • Trois patterns TypeScript qui améliorent l’architecture des applications React — Découvrez comment les patterns Repository, Observer et Builder utilisent le système de types de TypeScript pour créer des bases de code React et Next.js plus propres et plus faciles à maintenir.
  • Leçons tirées de la livraison d’un SaaS Next.js et de la création d’une CLI de scaffolding — Explique les décisions fondamentales — choix du stack, authentification, multi-tenancy, facturation et gestion de l’état — ainsi que la création d’une CLI qui simplifie la configuration des projets Next.js.