On est fin 2023. Un acteur de la rénovation énergétique en Île-de-France nous contacte avec un besoin clair : remplacer un ensemble de tableurs Excel par une application web. Ces tableurs — maintenus à la main depuis des années — servent à estimer les coûts de rénovation d’un bien immobilier, calculer les subventions MaPrimeRénov’, et générer des rapports PDF pour les particuliers. Ça marche, mais c’est fragile, impossible à partager entre agences, et chaque mise à jour des barèmes est une opération chirurgicale dans des cellules imbriquées.
Le cahier des charges tient en trois lignes : une app multi-utilisateurs avec gestion de rôles (agents, responsables, directeurs), des formulaires multi-étapes qui guident la saisie des caractéristiques d’un bien, et un moteur de calcul qui reproduit exactement les résultats des tableurs. Le tout déployable rapidement, maintenable sans nous, et capable d’évoluer quand les barèmes changent — ce qui arrive souvent dans la rénovation énergétique.
Pourquoi Next.js 14
Le timing est parfait. Next.js 14 vient de sortir fin octobre 2023 avec une annonce majeure : les Server Actions sont stables. Ce n’est pas un détail. Depuis Next.js 13.4, l’App Router et les React Server Components étaient utilisables en production, mais les Server Actions restaient expérimentales. Avec la version 14, on peut enfin construire des applications full-stack sans écrire une seule route API pour les mutations — les formulaires soumettent directement des fonctions serveur, avec validation, accès base de données, et redirection, le tout dans un seul fichier.
Pour un projet centré sur des formulaires complexes, ça simplifie tout. Avant, il fallait :
- Créer une route API
POST /api/form/step-1 - Écrire la logique de validation côté serveur
- Écrire un handler
fetchcôté client - Gérer les états de chargement et d’erreur manuellement
- Synchroniser les types entre client et serveur
Avec les Server Actions, tout ça disparaît. On écrit une fonction async marquée "use server", on la passe comme action d’un <form>, et React gère le reste — soumission, sérialisation des données, progressive enhancement. Le navigateur fait un POST natif, pas un fetch JavaScript. Si JS est désactivé, le formulaire fonctionne quand même.
// app/(signed-in)/form/s0/[formId]/step/1/action.ts
"use server";
import { z } from "zod";
import { redirect } from "next/navigation";
const schema = z.object({
ownerLastName: z.string().min(1),
ownerFirstName: z.string().min(1),
ownerEmail: z.string().email(),
departmentCode: z.string().length(2),
annualIncome: z.coerce.number().positive(),
});
export async function saveStep1(formData: FormData) {
const data = schema.parse(Object.fromEntries(formData));
await db.updateTable("PropertyS0")
.set(data)
.where("id", "=", formId)
.execute();
redirect(`/form/s0/${formId}/step/2`);
}
C’est lisible, c’est typé, c’est testable. Et surtout, c’est colocalisé avec le composant qui l’utilise — pas besoin de chercher dans un dossier /api pour comprendre ce que fait un formulaire.
L’architecture globale
L’architecture s’organise en couches, chacune avec une responsabilité claire :
React Server Components en surface. Chaque page est un Server Component qui charge ses données directement depuis la base — pas de useEffect, pas de state global, pas de cache client à invalider. Le HTML est rendu côté serveur et streamé au navigateur. Les parties interactives (sélecteurs, champs conditionnels) sont des Client Components isolés, importés avec "use client".
Server Actions pour toutes les mutations. Chaque étape du formulaire soumet vers une Server Action qui valide avec Zod, persiste en base, et redirige vers l’étape suivante. Pas de route API intermédiaire.
Services métier en couche isolée. Les calculs de coûts de travaux, de subventions, de couleur de foyer — toute la logique métier est encapsulée dans des classes de service injectables et testables unitairement. C’est la couche qui reproduit les formules Excel, et on y reviendra.
Kysely comme query builder typé, au-dessus de Vercel Postgres. Je ne connaissais pas Kysely avant ce projet — je venais de Knex et Drizzle, et j’avais utilisé Prisma sur d’autres missions. Mais en créant le projet depuis le template Vercel Postgres, Kysely était le choix par défaut via le package @vercel/postgres-kysely. J’ai commencé par curiosité, et je suis resté par conviction. En bon geek TypeScript, pouvoir définir le schéma de base de données à la main dans un fichier de types et avoir le compilateur qui vérifie chaque requête à la compilation — c’est ce dont j’avais toujours rêvé sans le savoir. Pas de client généré, pas de fichier de schéma à synchroniser, pas de runtime supplémentaire. Du SQL pur, typé de bout en bout.
const property = await db
.selectFrom("Property")
.where("id", "=", propertyId)
.where("userId", "=", userId)
.selectAll()
.executeTakeFirstOrThrow();
Si la colonne userId n’existe pas dans la table Property, TypeScript refuse de compiler. C’est ce niveau de sécurité qu’on veut sur un projet financier.
Vercel Postgres — le bon timing
Vercel Postgres est sorti en GA pour les plans Pro en octobre 2023, juste avant qu’on démarre. C’est un PostgreSQL managé propulsé par Neon, avec une intégration native dans l’écosystème Vercel : les variables d’environnement sont injectées automatiquement, le provisioning prend trente secondes, et les preview deployments ont chacun accès à la base sans configuration.
Pour une équipe de deux développeurs qui veut livrer vite sans gérer d’infrastructure, c’est pile ce qu’il nous fallait. On n’a pas passé une seule heure sur du DevOps base de données pendant les trois premiers mois du projet.
L’authentification avec NextAuth v5
NextAuth v5 (rebrandé Auth.js) est sorti en beta en octobre 2023, aligné avec Next.js 14. C’est la première version nativement conçue pour l’App Router — le middleware d’authentification s’intègre directement dans le nouveau système de routing.
On l’utilise avec un provider Credentials (email + mot de passe hashé avec Argon2) et des JWT. Le middleware protège les routes /form et /history :
// middleware.ts
export default auth((req) => {
const { pathname } = req.nextUrl;
if (pathname.startsWith("/form") || pathname.startsWith("/history")) {
if (!req.auth) return Response.redirect(new URL("/login", req.url));
}
});
Les callbacks JWT injectent le userId et le role dans le token, ce qui permet de vérifier les autorisations dans les Server Actions sans requête supplémentaire en base :
export function authorizeUser(roles: UserRole[]): string | null {
const session = await auth();
if (!session?.user?.role) return null;
if (!roles.includes(session.user.role)) return null;
return session.user.id;
}
Trois rôles : Agent (saisit les formulaires), ARAI (accès étendu), Directeur (vision globale). Chaque Server Action commence par vérifier le rôle — c’est systématique, pas optionnel.
Les formulaires multi-étapes
Le cœur du produit, c’est le parcours de saisie. Un agent immobilier visite un bien, ouvre l’application sur sa tablette, et remplit les caractéristiques en cinq ou six étapes selon le scénario de simulation.
Chaque scénario a sa propre route dynamique :
- S0 (simulation rapide) :
/form/s0/[formId]/step/[1-5]— 5 étapes - S1 (simulation avec DPE) :
/form/s1/[formId]/step/[1-6]— 6 étapes, inclut l’upload et l’analyse du DPE
L’URL reflète exactement l’état de progression. Pas de state management côté client, pas de contexte React partagé entre les étapes. Chaque étape est une page Server Component qui charge les données existantes depuis la base, affiche le formulaire pré-rempli, et soumet vers une Server Action qui sauvegarde et redirige.
Le modèle Property en base a un champ completedSteps qui traque la progression. Si un agent quitte en plein formulaire et revient trois jours plus tard, il retrouve exactement là où il en était. Pas de localStorage, pas de session — la base de données est la source de vérité unique.
Le moteur de calcul
C’est la partie la plus sensible du projet. Les formules Excel du client calculent des coûts de travaux, des surfaces d’isolation, des montants de subventions — avec des conditions imbriquées, des arrondis spécifiques, et des cas limites documentés nulle part ailleurs que dans les cellules du tableur.
On a traduit chaque formule en TypeScript, dans des services dédiés :
- WorkCostService — calcule le coût de chaque type de travaux (menuiseries, isolation, VMC, pompes à chaleur…) avec ou sans coordination
- SubventionsService — calcule les subventions MaPrimeRénov’ selon la couleur du foyer, le nombre de sauts DPE, et les plafonds réglementaires
- HomeColorDetector — détermine la couleur du foyer (bleu, jaune, violet, rose) en fonction du département, du nombre de personnes et des revenus
- PropertyS0AdditionalFieldsService — calcule les champs dérivés (surfaces d’isolation, nombre d’ITE/ITI)
La formule originale pour le calcul du nombre d’ITE, par exemple :
=SI(B3="";0;ARRONDI((-B6*0,3*B3*1,35-B7*0,3*B3*1,35
+B3*1,35*(B12+B19+B25)/B9/2,5);0))
Traduite en TypeScript :
computeNumITE(property: PropertyS0): number {
if (!property.surface) return 0;
const s = property.surface;
return Math.round(
-property.numExteriorDoors * 0.3 * s * 1.35
- property.numExteriorWindows * 0.3 * s * 1.35
+ s * 1.35 * (
property.level0Surface
+ property.level1Surface
+ property.level2Surface
) / property.ceilingHeight / 2.5
);
}
Chaque formule a été convertie avec un test unitaire écrit avant l’implémentation, en utilisant les valeurs du tableur comme fixtures. On utilise intensivement les tests tableaux de Jest (it.each / describe.each) pour systématiser : une seule définition de test, des dizaines de jeux de données extraits directement du tableur. C’est un pattern qu’on voit encore trop rarement dans les projets — la plupart des développeurs écrivent un test par cas, à la main. Avec it.each, on couvre des combinatoires entières en quelques lignes :
it.each([
{ surface: 90, doors: 2, windows: 8, expected: 73 },
{ surface: 120, doors: 3, windows: 12, expected: 94 },
{ surface: 65, doors: 1, windows: 6, expected: 55 },
// ... 15 lignes de plus, copiées du tableur
])(
"computes ITE count for surface=$surface",
({ surface, doors, windows, expected }) => {
const property = buildPropertyS0({ surface, numExteriorDoors: doors, numExteriorWindows: windows });
expect(service.computeNumITE(property)).toBe(expected);
}
);
On y reviendra dans un prochain article — le TDD a été un choix structurant pour ce projet.
L’intelligence documentaire
Le scénario S1 introduit la fonctionnalité la plus ambitieuse du projet : l’upload et l’analyse automatique du DPE (Diagnostic de Performance Énergétique). L’agent scanne le document DPE du bien, le télécharge dans l’application, et le système en extrait automatiquement les données pour pré-remplir le formulaire.
Le pipeline est en trois temps :
- Upload vers Vercel Blob avec un système de tokens et webhooks
- OCR via Google Document AI — extraction du texte brut depuis le PDF scanné
- Extraction structurée via OpenAI GPT-4 — le texte brut est envoyé à GPT-4 avec un schéma JSON précis, et le modèle retourne les données structurées (adresse, surface, année de construction, sources d’énergie, classe DPE, surfaces des murs…)
On valide la réponse de GPT-4 avec Zod avant de l’insérer en base. Si le modèle hallucine ou retourne un format inattendu, on tombe en fallback sur la saisie manuelle — l’agent est prévenu et peut corriger.
GPT-4 est disponible en API depuis juillet 2023, et depuis le DevDay de novembre 2023, le mode JSON est supporté nativement. On l’utilise pour contraindre la sortie, mais on ne fait pas confiance au modèle pour les calculs financiers — uniquement pour l’extraction d’information depuis un document non structuré.
L’intégration ADEME
En parallèle du pipeline IA, on intègre l’API ADEME pour récupérer les DPE officiels. L’API retourne du XML. Du XML en 2024, déjà, c’est un choix. Mais le pire, c’est que le format n’est pas standardisé : selon le logiciel utilisé par le diagnosticien pour produire le DPE, l’encodage des données change complètement. Les dates ne sont pas au même format, les valeurs numériques utilisent tantôt le point tantôt la virgule comme séparateur décimal, les noms de champs varient. Le même champ “surface habitable” peut s’appeler surface_habitable, shab ou SurfaceHabitable selon l’éditeur du logiciel. C’est le genre de choses qu’on voit en France et nulle part ailleurs, et c’est d’autant plus frustrant que c’est une API gouvernementale censée être un standard national. On a dû écrire un parser défensif qui gère les variantes connues et logue les inconnues pour les traiter au fil de l’eau. On parse ce XML avec un DOM parser custom (embarqué dans le projet pour fonctionner dans l’environnement serverless de Vercel) et on en extrait :
- L’adresse complète et le code postal
- Les sources d’énergie (biomasse, électricité, gaz, fioul)
- L’année de construction et la surface
- La classe énergétique DPE
- Les surfaces détaillées (murs extérieurs, combles perdus, rampants de toiture)
Ces données croisées avec l’analyse IA du document physique permettent de pré-remplir la quasi-totalité du formulaire S1. L’agent n’a plus qu’à vérifier et compléter les informations manquantes.
La génération PDF
Une fois la simulation calculée, l’agent génère un rapport PDF personnalisé pour le particulier. On utilise PDFMonkey, un service de génération PDF à partir de templates HTML — on envoie les données de la simulation via API, PDFMonkey rend le template et retourne un PDF prêt à envoyer.
Le process est asynchrone en deux temps : déclenchement de la génération, puis polling jusqu’à complétion. Les fonctions Vercel qui gèrent ce flow ont un timeout étendu à 300 secondes dans vercel.json — la génération peut prendre du temps selon la complexité du rapport.
Le déploiement
Tout tourne sur Vercel. Chaque push sur main déclenche un déploiement en production. Chaque pull request crée un preview deployment avec sa propre URL, connecté à la même base Postgres (en lecture seule pour les previews).
La CI GitHub Actions exécute deux suites de tests :
- Tests composants : vérification TypeScript + Jest sur les composants UI et les pages
- Tests modèles : Jest avec un vrai PostgreSQL (provisionné dans le workflow) pour les services métier
Les tests modèles tournent contre une vraie base, pas des mocks. C’est plus lent, mais c’est la seule façon de garantir que les migrations et les requêtes Kysely fonctionnent réellement.
Ce qu’on a posé
En deux mois, le socle est en place : authentification, formulaires multi-étapes, moteur de calcul, pipeline documentaire, génération PDF. Le tout sur un stack moderne, typé de bout en bout, avec une couverture de tests sur la logique métier critique.
Le pari de Next.js 14 avec Server Actions, Vercel Postgres, et Kysely s’est avéré payant. On a pu se concentrer sur la logique métier plutôt que sur la plomberie technique. Pas de backend séparé, pas d’API REST à maintenir, pas de base de données à administrer. Le framework fait le gros du travail, et le code métier reste lisible.
La suite — montée en charge, évolution des barèmes, ajout de scénarios — dira si ces fondations tiennent. Mais pour l’instant, le client a une application qui fait exactement ce que ses tableurs faisaient, en mieux, et accessible depuis n’importe quel navigateur.
La suite de cette série : Server Actions en production après six mois, puis le retex à 18 mois — TDD, montée de version et coordination d’IAs.