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

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
dataest stocké en texte (une colonnejsonb, 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.