htmx eingeordnet: HTML übers Netz statt JSON, und wann das die bessere Wahl ist
Ich habe denselben Veranstaltungskalender mit htmx, mit Unpoly und ohne Bibliothek gebaut: Code, Messwerte, Stolpersteine und eine ehrliche Entscheidungshilfe.
von Jean Pierre Kolb ·
Was halte ich eigentlich von htmx (öffnet in neuem Tab)? Gut, schlecht, nicht mehr zeitgemäß? Ehrlich gesagt hatte ich dazu lange nur eine Meinung, keine Erfahrung. Ich arbeite seit über 30 Jahren mit Websites, aber htmx habe ich in keinem Projekt eingesetzt. Eine Einschätzung ohne eigenen Code wäre nur eine Zusammenfassung dessen, was andere geschrieben haben.
Also habe ich für diese Reihe etwas gebaut: einen kleinen, fiktiven Veranstaltungskalender, einmal mit htmx, einmal mit Unpoly (öffnet in neuem Tab) und einmal ganz ohne Bibliothek. Dazu kommt eine vierte, bewusst fragile htmx-Fassung. Die Demo läuft live unter /demo/veranstaltungskalender/, und alles in diesem Artikel kannst du dort selbst nachklicken.
Dieser erste Teil ordnet ein: Was htmx eigentlich ist, wie dieselbe Funktion in den drei Varianten aussieht, wo ich beim Bau gestolpert bin und wann ich htmx heute einsetzen würde. Teil 2 misst, was Suchmaschinen und KI-Crawler von nachgeladenen Inhalten zu sehen bekommen. Teil 3 prüft, was beim Austausch von HTML für die Barrierefreiheit verloren geht.
Methodik-Hinweis: Versionen, Lizenzen und Browser-Unterstützung habe ich an Primärquellen geprüft: npm-Registry, offizielle Dokumentation, den Quellcode der gepinnten Versionen und MDN. Dateigrößen und das Verhalten unter der Content-Security-Policy habe ich selbst gemessen, am 29.09.2026 gegen die live ausgelieferte Demo. Eine Bewertung, die über diese Demo hinausgeht, kennzeichne ich als Einschätzung.
Die Idee: Der Server liefert HTML, nicht JSON
Die meisten modernen Web-Oberflächen folgen einem Muster. Der Server liefert Daten als JSON, und ein JavaScript-Framework im Browser baut daraus das HTML. Damit liegt die Logik doppelt vor: Validierung, Zustand und oft auch Routing existieren einmal auf dem Server und einmal im Client.
htmx dreht das zurück. Der Server liefert fertiges HTML, und htmx tauscht damit Teile der Seite aus. Gesteuert wird das über Attribute direkt im Markup:
<a href="/kalender/workshops/" hx-get="/kalender/workshops/" hx-target="#liste">Workshops</a>Ein Klick lädt die Adresse per Ajax, und die Antwort ersetzt den Inhalt von #liste. Kein JSON, kein Template im Browser, kein Build-Schritt. Das Projekt nennt diesen Ansatz Hypermedia: HTML ist nicht nur Darstellung, sondern trägt auch die möglichen nächsten Schritte in Form von Links und Formularen. Die ausführliche Begründung steht in den Essays auf htmx.org (öffnet in neuem Tab) und im frei lesbaren Buch „Hypermedia Systems" (öffnet in neuem Tab) von Carson Gross, Adam Stepinski und Deniz Akşimşek aus dem Jahr 2023.
Die harten Fakten zu htmx selbst:
- Größe: Das Projekt gibt „~16k min.gz'd" an. Nachgemessen:
htmx.min.jsder Version 2.0.11 ist 52.182 Byte groß, mit gzip (Stufe 9) komprimiert 16.838 Byte. - Abhängigkeiten: keine. Ein
<script>-Tag genügt, einen Build-Schritt brauchst du nicht. - Lizenz: Zero-Clause BSD (0BSD). Die ist noch freizügiger als MIT und verlangt nicht einmal einen Lizenzhinweis.
- Version: Auf npm ist 2.0.11 die aktuelle Version (
latest). htmx 4.0.0 ist am 28.08.2026 final erschienen, liegt aber absichtlich nur unter dem Tagnext. Das Projekt schreibt dazu, dass es 4.0 erst im Lauf des Jahres 2027 zulatestmachen will, damit bestehende 2.x-Projekte nicht versehentlich aktualisiert werden.
htmx 4 ist mehr als ein Pflegeupdate. Laut Release-Ankündigung (öffnet in neuem Tab) und „What's New" (öffnet in neuem Tab) nutzt es intern fetch() statt XMLHttpRequest, und Attribute vererben sich nur noch ausdrücklich über den Zusatz :inherited. Die Events heißen neu (etwa htmx:before:request), und der Verlaufs-Cache im Browser-Speicher fällt weg. Ich habe die Demo mit 2.0.11 gebaut, weil das die Version ist, die ein npm install htmx.org heute installiert. Wo sich das Verhalten zwischen den Versionen unterscheidet, sage ich es.
Die Demo: ein Veranstaltungskalender, viermal gebaut
Die Demo zeigt zwölf Termine einer erfundenen Stadtbibliothek in drei Kategorien. Der Inhalt ist bewusst banal, damit die Technik im Vordergrund steht. Jede Funktion steht für ein typisches htmx-Muster:
| # | Funktion | robuste Umsetzung | fragile Umsetzung |
|---|---|---|---|
| 1 | Liste → Detailseite | echte Links, hx-boost | – |
| 2 | Filter-Tabs | Link auf eine echte Kategorie-URL | Button, der ein Fragment ohne eigene URL lädt |
| 3 | Mehr laden | Link auf Seite 2, von htmx aufgewertet | Endlos-Scrollen mit hx-trigger="revealed" |
| 4 | Kasten „Nächste Termine" | steht im HTML | wird nach dem Laden nachgeholt (hx-trigger="load") |
| 5 | Kurzinfo als Dialog | <dialog> | Fragment in ein <div> getauscht |
Robust heißt: Jeder Link führt auch ohne JavaScript ans Ziel, htmx macht es nur schneller. Fragil heißt: Ohne JavaScript bleibt der Inhalt weg. Beides sieht im Browser gleich aus, und darum geht es in Teil 2 und 3.
Die robuste Fassung gibt es dreimal mit identischem Inhalt: mit htmx, mit Unpoly und ohne Bibliothek. Die fragile Fassung gibt es nur mit htmx. Alle Seiten und Fragmente erzeugt Eleventy aus einer einzigen Datendatei. Einen Server, der Antworten zusammenbaut, gibt es nicht, nur vorgerenderte HTML-Dateien. Beide Bibliotheken liefere ich selbst aus, in exakt gepinnten Versionen und ohne CDN. Die Demo läuft unter derselben Content-Security-Policy wie der Rest dieser Seite, und die erlaubt kein 'unsafe-eval'.
Dasselbe mit htmx
Die wichtigste Zeile steht ganz oben:
<body hx-boost="true">hx-boost macht aus jedem normalen Link und jedem Formular im <body> eine Ajax-Anfrage. Die Antwort ersetzt den Seiteninhalt, ohne dass der Browser die Seite neu lädt, und die URL in der Adresszeile wird trotzdem aktualisiert. Die Links bleiben dabei echte Links. Ohne JavaScript funktioniert die Seite genauso, nur eben mit komplettem Neuladen.
Für die Filter-Tabs reicht der Boost allein nicht, denn dort soll nur der Katalog getauscht werden, nicht die ganze Seite:
<a href="/db/demo/veranstaltungskalender/htmx/workshops/"
hx-get="/db/demo/veranstaltungskalender/htmx/workshops/"
hx-select="#katalog" hx-target="#katalog" hx-swap="outerHTML"
hx-push-url="true">Workshop</a>Der Link zeigt auf eine echte Seite, die alle Workshops auflistet. htmx lädt genau diese Seite, schneidet mit hx-select den Bereich #katalog heraus und ersetzt damit den aktuellen Katalog. hx-push-url schreibt die neue Adresse in den Verlauf, sodass Zurück-Taste und Lesezeichen funktionieren. Ein eigenes Fragment für den Tab brauche ich dafür nicht. Dieselbe URL dient als ganze Seite und als Quelle für den Ausschnitt.
„Mehr laden" funktioniert nach demselben Prinzip, nur hängt es an, statt zu ersetzen:
<p id="mehr">
<a href="/db/demo/veranstaltungskalender/htmx/seite/2/"
hx-get="/db/demo/veranstaltungskalender/htmx/seite/2/"
hx-select="#liste > li" hx-target="#liste" hx-swap="beforeend"
hx-select-oob="#mehr">Mehr laden</a>
</p>Zwei Details habe ich im Quellcode von htmx 2.0.11 nachgelesen. Erstens nimmt hx-select alle Treffer eines Selektors (intern querySelectorAll), hier also alle Listeneinträge von Seite 2. Zweitens tauscht hx-select-oob zusätzlich den Absatz #mehr gegen die Fassung von Seite 2 aus, auf der „Alle Termine geladen." steht. In htmx 2 wertet hx-select-oob dabei nur IDs aus, keine beliebigen CSS-Selektoren.
Für die Kurzinfo lädt htmx die Detailseite eines Termins, schneidet den Artikel heraus und setzt ihn in ein <dialog>. Das Öffnen erledigen fünf Zeilen eigenes JavaScript:
document.addEventListener("htmx:afterSwap", (event) => {
if (event.detail?.target?.id !== "dialog-inhalt") return;
const dialog = document.getElementById("kurzinfo-dialog");
if (dialog && !dialog.open) dialog.showModal();
});Das ?. in der zweiten Zeile steht dort nicht aus Vorsicht. Ohne es hat mein Skript einen Fehler geworfen, dazu mehr bei den Stolpersteinen.
Dasselbe mit Unpoly
Unpoly verfolgt dieselbe Idee wie htmx, trifft aber andere Voreinstellungen. Es stammt von Henning Koch bei der Agentur makandra, steht unter MIT-Lizenz und hat ebenfalls keine Abhängigkeiten. Die gepinnte Version ist 3.14.3.
Das Gegenstück zu hx-boost ist eine Zeile Konfiguration:
up.link.config.followSelectors.push("a[href]");Danach verfolgt Unpoly jeden Link selbst. Ausgenommen sind nur Links, bei denen das keinen Sinn ergibt, etwa Downloads, Links mit target, mailto: oder Adressen auf anderen Domains. Die Tabs und „Mehr laden" sehen so aus:
<a href="/db/demo/veranstaltungskalender/unpoly/workshops/"
up-target="#katalog" up-history="true">Workshop</a>
<a href="/db/demo/veranstaltungskalender/unpoly/seite/2/"
up-target="#liste:after, #mehr">Mehr laden</a>#liste:after hängt die Kinder des neuen #liste an das bestehende an, #mehr wird im selben Zug ersetzt. up-history="true" braucht es beim Tab, weil Unpoly den Verlauf von sich aus nur dann ändert, wenn der Hauptbereich der Seite getauscht wird. Das ist hier nicht der Fall.
Die Kurzinfo ist bei Unpoly ein Einzeiler, weil Overlays zum Lieferumfang gehören:
<a href="/db/demo/veranstaltungskalender/unpoly/termin/e01/"
up-layer="new modal" up-target="#detail">Kurzinfo</a>Das Overlay bringt Rolle, Fokusführung und Schließen per Escape selbst mit. Wie groß der Unterschied zu htmx an dieser Stelle ist, zeigt Teil 3.
Der Preis ist die Größe. unpoly.min.js ist 179.176 Byte groß, mit gzip 58.842 Byte. Das ist knapp das Dreieinhalbfache von htmx. Dazu kommt ein Stylesheet mit 1.130 Byte (gzip). Du bekommst dafür mehr fertige Bausteine und weniger eigenen Code.
Dasselbe ohne Bibliothek
Die dritte Fassung kommt ohne eine Zeile eigenes JavaScript aus. Tabs und „Mehr laden" sind gewöhnliche Links auf echte Seiten. Jeder Klick lädt eine neue Seite, und genau das ist der Punkt: Viele Oberflächen brauchen gar keinen Teilaustausch.
Damit sich die Seitenwechsel trotzdem nicht wie ein hartes Umschalten anfühlen, nutze ich View Transitions zwischen Dokumenten:
@media (prefers-reduced-motion: no-preference) {
@view-transition {
navigation: auto;
}
}Der Browser blendet dann von der alten zur neuen Seite über. Die Media Query sorgt dafür, dass das nur passiert, wenn du im Betriebssystem keine reduzierten Animationen eingestellt hast. Chrome und Edge unterstützen das ab Version 126, Safari ab 18.2. Firefox kann View Transitions zwischen Dokumenten bis heute nicht und lädt die Seite einfach normal. Das ist kein Schaden, denn die Seite funktioniert trotzdem. MDN führt die Funktion entsprechend nicht als Baseline.
Die Kurzinfo ist ein natives <dialog>, geöffnet ohne JavaScript über Invoker Commands:
<button type="button" commandfor="kurzinfo-e01" command="show-modal">Kurzinfo</button>
<dialog id="kurzinfo-e01" aria-labelledby="kurzinfo-e01-titel">
<h2 id="kurzinfo-e01-titel">Krimiabend mit Lokalautorin</h2>
…
<button type="button" commandfor="kurzinfo-e01" command="close">Schließen</button>
</dialog><dialog> mit showModal() ist seit September 2024 „Baseline widely available". Die Attribute commandfor und command sind jünger: Chrome und Edge ab 135, Firefox ab 144, Safari ab 26.2. Seit dem 12.12.2025 sind sie Baseline, aber noch nicht „widely available". Für die Plattform-Werkzeugkiste gehört auch das Attribut popover (öffnet in neuem Tab) dazu (Baseline seit April 2024), das die Demo nicht braucht.
Was ohne Bibliothek fehlt, ist der Teilaustausch. „Mehr laden" ist hier ein Wechsel auf Seite 2, nicht das Anhängen an die bestehende Liste. Und weil der Browser die Kurzinfo nicht nachladen kann, stehen alle sechs Dialoge vollständig im HTML der Liste. Bei sechs Terminen ist das egal, bei sechshundert nicht mehr.
Turbo, Datastar und fixi im Kurzporträt
Drei weitere Kandidaten habe ich nicht live gebaut, sondern an ihren Primärquellen eingeordnet:
| Turbo | Datastar | fixi | |
|---|---|---|---|
| Version (29.09.2026) | 8.0.23 | 1.0.4 (GitHub-Release; das npm-Paket ist veraltet) | 0.9.4 |
| Lizenz | MIT | MIT | 0BSD |
| Herkunft | 37signals, Referenz-Integration Ruby on Rails | Star Federation (gemeinnützig) | Big Sky Software, Carson Gross (auch htmx) |
| Idee | Drive (Seitenwechsel), Frames (Bereiche), Streams (Server-Updates) | reaktive Signale im Browser plus Server-Aktionen, Markenzeichen Server-Sent Events | htmx aufs Minimum reduziert: 90 Zeilen, 1.473 Byte gzip |
Turbo (öffnet in neuem Tab) kommt aus der Rails-Welt, setzt Rails aber nicht voraus. Die Entsprechung zu meinem Tab-Wechsel ist ein Frame:
<turbo-frame id="katalog">
<a href="/kalender/workshops/">Workshop</a>
…
</turbo-frame>Ein Link innerhalb des Frames ersetzt nur den Frame, und zwar durch den gleichnamigen Frame aus der Antwort.
Datastar (öffnet in neuem Tab) wird oft auf Server-Sent Events reduziert. Das greift zu kurz: Laut Dokumentation akzeptieren seine Server-Aktionen auch einfaches HTML als Antwort. SSE brauchst du erst, wenn der Server mehrere Aktualisierungen hintereinander schicken soll. Live vorführen kann ich es in meiner statischen Demo trotzdem nicht sinnvoll, weil sein eigentlicher Kern ein Server ist, der mitspielt.
fixi (öffnet in neuem Tab) ist das interessanteste Experiment der drei: sechs Attribute, keine Vererbung, keine Lade-Indikatoren, kein Verlauf. Der Autor beschreibt es als das Scheme zu htmx' Common Lisp. Für den Tab sähe das etwa so aus:
<button fx-action="/kalender/workshops/" fx-target="#katalog" fx-swap="outerHTML">Workshop</button>Wer verstehen will, was htmx im Kern tut, liest die 90 Zeilen von fixi in einer Viertelstunde.
Stolpersteine beim Bau
Die CSP und eval
Einige htmx-Funktionen erzeugen zur Laufzeit JavaScript aus Attributtexten, per new Function: die hx-on-Handler, Event-Filter in hx-trigger wie keyup[key=='Enter'] und Werte mit dem Präfix js: in hx-vals und hx-headers. Eine Content-Security-Policy ohne 'unsafe-eval' verbietet genau das, und diese Seite hat keins.
Ich habe deshalb allowEval: false gesetzt:
<meta name="htmx-config" content='{"allowEval":false}'>Damit führt htmx einen hx-on-Handler gar nicht erst aus und meldet htmx:evalDisallowedError. So habe ich es live gemessen. Ohne diese Einstellung ist das Ergebnis dasselbe, nur lauter: Die CSP blockiert new Function mit einem EvalError in der Konsole.
Der tückischste Punkt steht im Quellcode. Bei einem Event-Filter fällt htmx mit allowEval: false auf einen Filter zurück, der immer true liefert. Ein hx-trigger="keyup[key=='Enter']" feuert dann bei jeder Taste, nicht nur bei Enter. In der Konsole steht dazu nur einmal beim Initialisieren ein allgemeines htmx:evalDisallowedError, das nicht verrät, dass der Filter jetzt ignoriert wird. Wenn du htmx unter einer strengen CSP betreibst, verzichte auf Event-Filter und löse die Bedingung auf dem Server oder in einem eigenen Skript.
Dazu eine Warnung zur Messmethode, weil sie mich selbst auf eine falsche Fährte geführt hat. Mein erster Test lief über die Skript-Auswertung des Test-Browsers (Playwright), und dort lief der hx-on-Handler trotz CSP durch. Code, den die Entwicklerwerkzeuge anstoßen, ist von der Eval-Sperre ausgenommen. Erst als die Seite den Code selbst auslöste, über einen echten Klick, kam der EvalError. Wer CSP-Verhalten automatisiert prüft, muss es aus dem Seitenkontext heraus tun.
Die Lade-Indikatoren und style-src
htmx fügt beim Laden ein eigenes <style>-Element für seine Lade-Indikatoren in den <head> ein. Diese Seite erlaubt 'unsafe-inline' bei style-src, deshalb fällt das nicht auf. Unter einer strengeren Policy blockiert der Browser das Element. Für diesen Fall bietet htmx zwei Wege: inlineStyleNonce setzen oder includeIndicatorStyles abschalten und die Regeln für .htmx-indicator selbst ins Stylesheet schreiben.
URLs, die kein Plugin umschreibt
Diese Seite liegt im Unterordner /db/. Eleventy setzt das Präfix über ein Plugin automatisch vor jedes href und src. Ein Blick in dessen Attributliste zeigt aber: hx-get und hx-push-url stehen nicht darin. Ohne Gegenmaßnahme hätte htmx /demo/… statt /db/demo/… geladen. Die htmx-Attribute bekommen das Präfix deshalb im Template ausdrücklich. Das Muster ist allgemein: Jedes Werkzeug, das URLs umschreibt, kennt nur die Attribute, die es kennt, und hx-* gehört selten dazu.
Events ohne Ziel
Mein Fünf-Zeilen-Skript für den Dialog hatte in der ersten Fassung kein ?.. Beim Klicktest ist mir das zunächst nicht aufgefallen, weil ich die Konsole der robusten htmx-Seite nicht einzeln geprüft habe. Aufgefallen ist es erst beim zweiten Durchgang: Nach einem Klick auf „Zurück" stellt htmx die Seite aus seinem Verlaufs-Speicher wieder her und löst dabei htmx:afterSwap ohne detail.target aus. Mein Skript griff auf target.id zu und warf einen Fehler. Die Korrektur waren zwei Fragezeichen, die Lehre ist größer: Bei htmx-Events nie davon ausgehen, dass alle Felder gefüllt sind.
Übrigens speichert htmx 2 diese Snapshots seit Version 2.0.5 im sessionStorage, davor lagen sie im localStorage.
Entscheidungshilfe
Was folgt aus einer Demo mit zwölf Terminen? Keine Wahrheit über große Anwendungen, aber eine klare Tendenz. Das ist meine Einschätzung:
htmx passt, wenn deine Anwendung serverseitig gerendert wird und an einzelnen Stellen lebendiger werden soll: Filter, Nachladen, Formulare mit Rückmeldung. Die Bibliothek ist klein, hat keine Abhängigkeiten und lässt sich schrittweise einbauen. Ihre Stärke ist, dass sie wenig vorschreibt. Das ist zugleich ihre Schwäche: Fokus, Ansagen für Screenreader und Seitentitel musst du selbst regeln. Teil 3 zeigt, wie viel das in der Praxis ist.
Unpoly passt, wenn du dasselbe Prinzip willst, aber mehr fertige Antworten brauchst: Overlays, Fokusführung, Verlauf. Du zahlst mit knapp dreieinhalbfacher Dateigröße und einer größeren API.
Keine Bibliothek passt häufiger, als ich vor dem Bau gedacht hätte. Echte Seitenwechsel mit View Transitions und native Dialoge decken einen großen Teil dessen ab, wofür ich früher zu Ajax gegriffen hätte. Fehlen tut nur der Teilaustausch.
Ein Client-Framework passt weiterhin dort, wo viel Zustand im Browser lebt: Tabellenkalkulationen, Karten, Spiele, Offline-Betrieb. Dafür ist htmx nicht gemacht, und das sagt das Projekt im Essay „When Should You Use Hypermedia?" (öffnet in neuem Tab) auch selbst.
htmx 2 oder 4? Für ein neues Projekt würde ich mir 4 ansehen, weil dort die Zukunft der Bibliothek liegt. Für ein bestehendes Projekt gibt es keinen Grund zur Eile. Das Projekt selbst lässt 2.x bis ins Jahr 2027 als Standard stehen.
Wie es weitergeht
Eine Frage lässt dieser Teil bewusst offen: Was sehen eigentlich Suchmaschinen und KI-Systeme von Inhalten, die htmx nachlädt? Das messe ich in Teil 2 mit Prüfmarken in jedem Inhaltsblock, per curl, im Headless-Browser und mit einem KI-Assistenten. Teil 3 geht mit der Tastatur durch alle vier Varianten und misst, wohin der Fokus nach jedem Austausch wandert. Die Übersicht über alle Teile findest du in der htmx-Reihe.