Bob le développeur
Estimer

Formulaire multi-étapes avec sauvegarde progressive : notre architecture NestJS

Bob Le Développeur

26/02/2026 · Mis à jour le 14/10/2026

Sur un projet legaltech, on a dû construire un formulaire de 17 étapes avec upload de documents, reprise de session, et rejet par étape côté admin. Voici l'architecture qui a tenu en prod.

Le besoin

L'utilisateur devait fournir des données personnelles, des choix fiscaux, et des documents justificatifs sur 17 étapes. Personne ne fait ça en une session. Il fallait pouvoir :

  • Sauvegarder chaque étape indépendamment
  • Reprendre là où on s'est arrêté, même 3 jours plus tard
  • Permettre à l'admin de rejeter une étape spécifique sans toucher aux autres
  • Renvoyer l'utilisateur directement sur l'étape à corriger

Le modèle : une entité par étape, pas par champ

La tentation naturelle, c'est de créer un gros modèle avec une colonne par champ. Avec 17 étapes et des dizaines de champs, ça devient un monstre ingérable. On a pris l'approche inverse : une entité générique qui stocke les données de chaque étape en JSON.

@Entity({ name: 'forms' })
@Unique(['recordId', 'step']) // une seule ligne par dossier et par étape, garanti par la base
export class FormsEntity extends BaseEntity {
  @Column({ nullable: false })
  data: string; // JSON stringifié

  @Column({ nullable: false })
  step: string; // ex: "nationality", "activity", "payment"

  @Column({ type: 'boolean', nullable: true })
  validated: boolean | null; // null = en attente, true = validée, false = rejetée

  @Column({ type: 'uuid' }) // même type que la clé primaire de RecordEntity
  recordId: string;

  @ManyToOne(() => RecordEntity, (record) => record.forms)
  @JoinColumn({ name: 'recordId' })
  record: RecordEntity;
}

Chaque étape = une ligne en base. Le champ data contient le JSON de l'étape. Le champ step identifie de quelle étape il s'agit.

Sauvegarder et récupérer une étape

L'API expose deux endpoints simples :

// Sauvegarder une étape
@Post('/forms')
async saveStep(@Body() dto: SaveFormDto, @GetCurrentUser() user: CurrentUser) {
  await this.assertOwner(dto.recordId, user);
  // Crée ou met à jour la ligne pour ce step, et la remet en attente de validation
  await this.formsRepository.upsert(
    { recordId: dto.recordId, step: dto.step, data: dto.data, validated: null },
    ['recordId', 'step'],
  );
  return this.formsRepository.findOne({ where: { recordId: dto.recordId, step: dto.step } });
}

// Récupérer une étape
@Get('/forms/:step/records/:recordId')
async getStep(
  @Param('step') step: string,
  @Param('recordId') recordId: string,
  @GetCurrentUser() user: CurrentUser,
) {
  await this.assertOwner(recordId, user);
  return this.formsRepository.findOne({ where: { recordId, step } });
}

// Un utilisateur ne lit et n'écrit que les étapes de ses propres dossiers
private async assertOwner(recordId: string, user: CurrentUser) {
  const owned = await this.recordRepository.existsBy({ id: recordId, userId: user.id });
  if (!owned) {
    throw new NotFoundException('Dossier introuvable');
  }
}

Le frontend appelle saveStep à chaque fois que l'utilisateur passe à l'étape suivante (ou clique "sauvegarder"). Au chargement, il appelle getStep pour pré-remplir le formulaire.

Les 17 étapes typées

Chaque étape a son propre type TypeScript dans un dossier types/steps/ :

placeOfBirth, nationality, activity, commercialName,
contactDetails, address, secondAddress, socialSecurityNumber,
socialCotisation, revenuRhythm, hasAcre, identity,
pouvoirConfie, proofOfResidence, proofOfNonConviction,
diploma, payment

Le data JSON est parsé côté service puis contrôlé par du code propre à chaque étape : le type TypeScript de l'étape, effacé à la compilation, ne vérifie rien à l'exécution. Si l'étape est nationality, on vérifie que les champs obligatoires (pays, date de naissance, visa éventuel) sont présents.

Le rejet par étape côté admin

L'admin peut rejeter une étape spécifique :

POST /admin/records/:id/reject/:step

Le système passe le dossier en statut MISSING_INFORMATION et envoie un email à l'utilisateur avec un lien direct vers l'étape à corriger (/formulaire?step=nationality). L'utilisateur corrige uniquement cette étape : ses 16 autres étapes restent intactes.

C'est le gros avantage de l'approche "une ligne par étape" : on peut invalider une étape sans toucher aux autres.

Les trade-offs

Ce qu'on gagne :

  • Flexibilité totale : ajouter une étape = ajouter un type, pas une migration
  • Rejet ciblé : l'admin rejette l'étape 3 sans impacter l'étape 12
  • Reprise de session : chaque étape est sauvegardée indépendamment
  • Évolution facile : modifier les champs d'une étape n'impacte pas les données existantes des autres étapes

Ce qu'on perd :

  • Pas de requêtes SQL simples sur les champs individuels tant que data est stocké en texte (une colonne jsonb, que PostgreSQL sait interroger et indexer, lèverait cette limite)
  • Validation à deux niveaux : le DTO valide la structure, le service valide le contenu métier
  • Le JSON.parse() / JSON.stringify() est un risque si on n'est pas rigoureux sur le typage

Notre tip principal

Ne stockez pas tout dans un seul JSON géant. On a vu des projets avec un seul document JSON de 200 champs. C'est impossible à rejeter partiellement, impossible à valider étape par étape, et une source de bugs quand deux onglets sauvegardent en même temps.

Une ligne par étape, c'est plus de lignes en base, mais c'est infiniment plus simple à gérer.

En contexte : on a utilisé ce pattern sur un projet legaltech d'automatisation INPI (17 étapes, 8 types de documents, 12 statuts de dossier).

Stack concernée : NestJS, TypeORM, PostgreSQL, React

Ce contenu vous a servi ? Ajoutez Bob le développeur à vos sources préférées sur Google.

Renfort

Besoin de renfort dans votre équipe ?

Des développeurs React, Next.js et Node.js seniors, intégrés à vos sprints et à vos revues de code.

  • Démarrage en quelques jours
  • Engagement au mois

30 min, gratuit, sans engagement.

La newsletter qu’on n’ignore pas

Abonnez-vous à notre newsletter pour recevoir nos derniers articles, retours d’expérience et conseils tech directement dans votre boîte mail.

Désinscription en un clic. Vos données restent privées.