En juillet 2024, j’ai publié ici Why I Chose HTMX Over React and VueJS. J’y racontais six mois de refonte du front de Front Desk, mon SaaS pour l’hôtellerie, en remplaçant Vue.js par HTMX. J’étais convaincu et l’article était sincère.

Deux ans plus tard, je pense que c’était une erreur. Je l’ai évoqué au passage dans le second billet sur la migration vers Rust, mais le sujet mérite son propre article, parce que la question revient sans arrêt et parce que je vois passer régulièrement les arguments que j’avançais moi-même à l’époque.

Ce qui était vrai au bout de six mois

Tout ce que j’ai écrit en 2024 était exact à ce moment-là. J’avais supprimé plus de 3 000 lignes de JavaScript, la logique n’était plus dupliquée entre le backend et le front, et je n’avais plus d’API web à maintenir en parallèle de celle des applications mobiles.

La vitesse au démarrage est réelle. Une vue Django, un template partiel, trois attributs dans le HTML, et l’interaction fonctionne. Pas de build, pas de node_modules, pas de contrat d’API à tenir entre deux codebases. Quand on est seul et qu’on n’est pas expert front, c’est sexy.

Mais à la différence d’un framework front plus traditionnel où la complexité et l’effort arrivent en général au début, avec HTMX le piège se referme bien plus tard.

Si HTMX nous permet d’avancer vite et si on ne voit aucune dette s’accumuler, parce que ce qu’on écrit ressemble à du Django parfaitement ordinaire : des vues, des templates, des URLs. Rien qui ressemble à un framework front maison. Sauf que c’est exactement ce qu’on est en train de construire, un morceau à la fois.

Le coût de chaque interaction

Quelques chiffres du codebase Django de Front Desk, sept ans de production : environ 400 templates HTML, dont 50 partials.

Chaque interaction un peu vivante coûte trois choses : un template partiel, une URL, et une vue qui n’existe que pour renvoyer ce fragment. La première fois, ça ne coûte rien. Au bout de deux ans, on se retrouve à avoir des tas de snippets html qui se baladent, puisque HTMX n’a pas cette approche de composabilité et de réutilisabilité des templates, beaucoup de partials avec une logique similaire se retrouvent dupliqués, les interactions doivent passer par du JavaScript ou des librairies comme Alpine, on découvre alors qu’on recrée petit à petit un framework front.

Un composant front dans la majorité des frameworks Frontend, même écrit à la va-vite, se lit à un seul endroit : son CSS, son état et son comportement sont dans le même fichier. Un fragment HTMX se lit au minimum à 5 endroits : le template qui déclenche, l’entrée dans urls.py, la vue, le template renvoyé et le CSS. La logique d’interface existe toujours, elle est simplement répartie dans des fichiers qui ne se ressemblent pas et qu’aucun outil ne relie entre eux.

Le serveur finit par connaître le DOM

hx-target="#ticket-list" signifie que la vue Django sait, indirectement, qu’il existe un div nommé ticket-list dans une page qu’elle ne rend pas elle-même. Renommer cet identifiant dans le template principal ne casse rien au démarrage et ne casse rien dans les tests : l’interaction se contente de ne plus rien faire en production.

C’est un couplage silencieux entre le backend et la structure de la page, et rien dans l’écosystème ne le vérifie. Pas de compilateur, pas de typage, pas de lint pour dire que la cible n’existe plus. Avec 149 cibles déclarées à la main, ça finit forcément par arriver.

S’ajoutent les cas où le modèle « une requête, une cible » ne suffit plus. Il faut alors sortir hx-swap-oob pour mettre à jour deux endroits de la page en même temps, ou hx-push-url pour que l’historique du navigateur reste cohérent. Ces deux attributs n’apparaissent que quatre fois chacun dans tout le projet, et ce n’est pas parce que le besoin n’existait pas. C’est parce qu’à chaque fois, j’ai préféré contourner plutôt que d’ajouter une convention de plus à retenir.

Il y a aussi les problèmes qui paraissent simples et ne le sont pas. Le bouton retour du navigateur, par exemple : mal câblé, il réaffiche un fragment seul, hors de sa page. Le correctif tient dans quelques attributs, mais il faut d’abord comprendre comment HTMX met la page en cache, et la documentation sur ces cas-là est mince. Chaque fois, on perd une demi-journée sur quelque chose qu’un routeur front règle sans qu’on y pense.

Tout ce que j’ai réécrit à la main

Voilà le point qui m’a le plus marqué en relisant le code. J’ai un middleware de 93 lignes dans core/middleware.py dont le seul travail est de prendre les messages Django (messages.success(...)) et de les réinjecter dans un header HX-Trigger en JSON. Il doit fusionner proprement avec un HX-Trigger que la vue aurait déjà posé, gérer les deux syntaxes possibles de ce header, et compter les messages présents avant l’exécution de la vue pour ne pas les afficher deux fois. Quatre-vingt-treize lignes de Python pour afficher un toast.

À côté, il y a un décorateur htmx_only qui redirige quand la requête ne vient pas de HTMX, parce que ces URLs de fragments ne sont pas des pages et n’ont aucun sens si un utilisateur les ouvre directement. Et des vues qui se dédoublent en interne selon request.htmx, le drapeau que pose django-htmx, puisque Django ne sait pas distinguer tout seul une requête HTMX d’une requête normale.

Aucune de ces briques n’est difficile à écrire. Toutes existent gratuitement dans n’importe quel framework front, où un toast est un composant et où une route de fragment n’existe tout simplement pas. Ce code d’interface, je l’ai déplacé en Python et réécrit moi-même, en croyant l’avoir supprimé.

Django a laissé passer le tournant

Il y a une raison plus large à tout ça, et elle ne tient pas à HTMX. Rester au plus près de Django, c’était réutiliser les templates, les formulaires, les URLs. Ces briques étaient excellentes il y a dix ans. Elles ont vieilli, et le framework n’a rien proposé pour prendre le virage.

Les formulaires restent pénibles à styliser dès qu’on sort du rendu par défaut. Il n’y a pas de composant réutilisable au sens où l’entend n’importe quel framework front, juste des templates qu’on inclut. Il n’y a pas de pipeline d’assets dans le cœur du framework, donc pas de chemin naturel vers npm. Et il n’y a évidemment ni typage ni outillage de test pour ce qui se passe dans la page.

Rails a fait l’autre choix avec Hotwire et Turbo : livrés avec le framework, pensés avec lui, documentés par la même équipe. Django a laissé le sujet à l’écosystème. On trouve un paquet pour presque chaque manque, django-htmx, django-template-partials, django-components, mais ce sont des rustines choisies une par une, sans direction commune, et sans rien qui relie les morceaux entre eux.

L’état, c’est le backend

Quand on travaille avec React, Vue ou Svelte, on raisonne avec un état côté client : l’interface est une fonction de cet état, UI = f(state). On modifie l’état, l’écran se recalcule tout seul.

HTMX supprime cette couche. L’état, c’est la base de données, et l’écran est le rendu direct du backend. Le gain est réel et je ne veux pas le minimiser, parce que c’est l’argument qui m’avait convaincu : il n’y a plus de désynchronisation possible entre ce que le serveur sait et ce que l’utilisateur voit. Toute une famille de bugs disparaît, celle où le front affiche une donnée périmée parce qu’on a oublié d’invalider un cache ou de rafraîchir un store.

Le prix, c’est que toute la complexité d’interface remonte dans le backend. Elle change simplement de fichier. Concrètement, dans mes vues Django : des conditions pour distinguer une requête HTMX d’une requête normale, des vues qui renvoient un partiel plutôt qu’une page, des URLs qui n’existent que pour un bout d’écran. Quand on a l’habitude de Django et de ses classes, ça semble anodin la première fois. Sur une interface qui bouge beaucoup, avec des filtres combinés, des exports, du contenu modifiable à l’intérieur d’autre contenu, ça devient vite pénible, même avec tout ce que HTMX fournit pour aider (hx-swap, hx-swap-oob, les triggers personnalisés).

Il y a aussi ce qu’on perd sans le voir : l’écosystème JavaScript. On peut toujours ajouter une bibliothèque par CDN ou par npm, mais Django et npm ne se marient pas naturellement, et un projet Django classique n’a le plus souvent aucune étape de build front. On finit par prendre ce qui s’installe en une balise <script>, éviter ce qui demande un bundler, et réécrire à la main ce qu’une bibliothèque moderne fournirait.

Ce que le backend ne peut pas savoir

HTMX fonctionne très bien quand l’état de l’écran correspond à l’état de la base. Une liste, un formulaire, une suppression, une pagination. Ça décrit correctement une application de contenu.

Front Desk est un outil de travail ouvert toute la journée. Il y a en permanence de l’état qui n’existe nulle part en base : une sélection multiple en cours, un panneau latéral ouvert, un filtre pas encore appliqué, une saisie en cours pendant qu’une autre zone de la page se rafraîchit. HTMX n’a pas de réponse à ça. On le repousse dans l’URL, dans des hx-vals, dans des attributs data, ou on rajoute du JavaScript et on revient au point de départ.

Le même problème se pose avec les bibliothèques tierces. Dès qu’un script manipule le DOM de son côté, HTMX remplace des morceaux de page dans son dos et la bibliothèque ne sait pas que le contenu a changé. Un tri par glisser-déposer, un sélecteur de date, un éditeur de texte : tous demandent une réinitialisation manuelle après chaque swap. Ça se règle, et ça se règle à chaque fois, sur chaque écran.

Reste la latence. Chaque interaction est un aller-retour serveur. Sur le réseau d’un hôtel, avec un technicien qui coche ses tâches depuis son téléphone au sous-sol, ça se sent immédiatement.

L’API qu’on finit toujours par écrire

C’est l’argument que j’avançais le plus fort en 2024, et c’est celui sur lequel je me suis le plus trompé. En gardant le front web collé à Django, je n’avais qu’une seule API à maintenir, celle de l’application mobile. Le reste, c’était des vues, des templates, des formulaires, des URLs : du Django ordinaire, avec tout ce que le framework fournit déjà.

L’économie est réelle, mais elle est temporaire. Sur un projet qui dure, l’API finit par arriver de toute façon, et pas seulement pour le mobile. Front Desk a une application Flutter, donc une API. Il a des partenaires à qui exposer des données. Il a maintenant des agents qui veulent s’y brancher. Aucune de ces demandes ne passe par un template Django.

À partir du moment où cette API existe, et qu’elle est de toute façon la surface la plus stable du produit, le calcul change complètement. Le front web n’est plus un client supplémentaire qu’on s’épargne : c’est un consommateur de plus pour un contrat qui existe déjà, lui typé et testé. Ce que je croyais éviter, je l’ai simplement retardé, et je l’ai payé pendant deux ans sous la forme d’un front que rien ne vérifie.

Cette économie ne tient que sur un CRUD tranquille dont personne d’autre ne consommera jamais les données. Ce n’est pas une hypothèse très solide pour un produit qui grandit.

Un front qu’aucun outil ne vérifie

C’est le point qui compte le plus pour moi aujourd’hui, et la question ne se posait pas dans les mêmes termes en 2024.

Depuis que je développe avec des agents, mon besoin numéro un est la vérifiabilité. Je produis beaucoup plus de code, je le relis, et je m’appuie sur tout ce qui peut attraper une erreur avant moi : un compilateur, un typage, une suite de tests, une pipeline CI. C’est exactement ce que je disais de Rust dans l’article sur la migration : un langage exigeant renvoie l’agent à ses erreurs avant que le programme ne tourne.

HTMX n’offre rien de tout ça. Pas de TypeScript, pas de typage sur les données qui traversent un fragment, pas de test de composant. Le contrat entre le serveur et la page tient dans des chaînes de caractères écrites à la main : un identifiant CSS dans hx-target, un nom d’événement dans hx-trigger, une clé JSON dans hx-vals. Rien ne les vérifie, ni au build, ni au démarrage, ni en intégration continue.

On me répond souvent qu’il suffit d’écrire des tests de bout en bout. J’en ai, ils attrapent des choses, et ils ne remplacent pas ce dont je parle. Un test E2E constate une erreur à l’exécution, longtemps après qu’elle a été écrite, là où un typage la signale pendant qu’on l’écrit. Il lui faut une session de navigateur pour vérifier ce qu’un compilateur vérifie en quelques millisecondes. Et il faudrait un scénario par cible pour couvrir 149 hx-target, alors qu’un identifiant renommé est précisément le genre d’erreur que personne ne pense à tester avant de l’avoir vue en production.

D’où une asymétrie assez franche entre les deux moitiés du projet. Le backend Django, celui-là même que je suis en train de migrer vers Rust, est typé, testé, couvert, branché sur une pipeline qui refuse un merge dès que quelque chose casse. Le front est un ensemble d’attributs HTML dont la seule validation est un humain qui clique dessus. Quand une part croissante du code est écrite par un agent, cette moitié invérifiable devient l’endroit par lequel les régressions passent.

Avec Svelte et TypeScript, une erreur de nom de propriété ou de forme de données est signalée au moment où on l’écrit. Avec HTMX, elle se manifeste par un bouton qui ne fait rien en production.

La question que je ne me suis pas posée

Mon erreur tient surtout à la question que je me posais au départ. En 2024, je raisonnais en termes de SPA contre hypermédia, un débat de fond passionnant qui n’avait pas grand-chose à voir avec ce que j’étais en train de construire.

Le critère utile, c’est la densité d’interaction de l’application, et la durée pendant laquelle un utilisateur reste sur le même écran. Un blog, un site de contenu, un back-office, un outil interne ouvert trois fois par jour : HTMX est parfait. Un logiciel métier ouvert huit heures d’affilée, avec des écrans denses, de la manipulation directe et du temps réel : c’est un framework front, et le coût d’entrée est vite amorti.

L’expression content-focused revient dans mon article de 2024 pour décrire Front Desk. Je le pensais vraiment. C’était une description honnête de ce que l’application faisait ce jour-là, et une très mauvaise prédiction de ce qu’elle allait devenir. Un outil utilisé quotidiennement par des professionnels dérive toujours vers plus d’interaction, parce que ce sont précisément les demandes que remontent les utilisateurs qui s’en servent le plus.

Il y a enfin une part de ce choix que je n’ai pas écrite à l’époque, et qui pesait sans doute plus lourd que mes arguments techniques. Prendre HTMX, c’était aussi une position : un refus de l’inflation des frameworks front, de la taille des bundles, de la quantité de machinerie qu’il faut assembler pour afficher une liste. Ce refus reste sain. Ce n’est simplement pas un critère d’architecture, et je l’ai traité comme s’il en était un.

Ce que je fais maintenant

Le nouveau front de Front Desk est en Svelte avec TypeScript, et c’est un vrai plaisir. Le typage attrape la moitié des erreurs d’interface avant l’exécution, les composants se lisent à un seul endroit, et l’écosystème est suffisamment conventionnel pour que les agents IA y travaillent bien, ce qui compte maintenant que je développe avec eux.

Ce qui a changé depuis 2024, c’est qu’on n’est plus obligé de choisir entre les deux camps. Je suis passé par React et par Vue, en entreprise comme sur mes projets, et Svelte est ce qui se rapproche le plus de ce que j’allais chercher chez HTMX : peu de cérémonie, un composant qui se lit d’un coup d’œil, et pas de mégaoctets de JavaScript expédiés au navigateur. La simplicité que je cherchais dans l’absence de framework, je la trouve aujourd’hui dans le framework lui-même. HTMX est d’ailleurs une bibliothèque JavaScript, ce qu’on oublie souvent dans les discussions sur le sujet.

Je ne veux pas non plus réécrire l’histoire. Ces deux ans sous HTMX ont fonctionné, l’application a tourné, les clients l’ont utilisée, et le passage par Django m’a permis de rester concentré sur le métier pendant une période où c’était exactement ce dont j’avais besoin. Le problème est venu de la durée. Un choix qui vous fait gagner six mois et vous en coûte dix-huit reste un mauvais choix, même s’il était agréable au début.

Là où HTMX reste le bon choix

Rien de tout ça ne veut dire que HTMX est mauvais, et je continue de le recommander dans les bons contextes. Un back-office Django, un outil interne, un CRUD, un formulaire multi-étapes, une application de contenu avec un peu d’interactivité : c’est le meilleur rapport effort/résultat que je connaisse, et ça reste vrai.

Le conseil que je me donnerais rétrospectivement tient en une question. Est-ce que je construis quelque chose qu’on consulte, ou quelque chose dans lequel on travaille ? Si c’est la première réponse, HTMX est un excellent pari. Si c’est la seconde, prenez un framework front dès le premier jour : le temps que vous croyez gagner en l’évitant, vous le rendrez avec les intérêts.