Diese Anleitung fasst zusammen, was wir beim Bau mehrerer Projekte mit Symfony und htmx gelernt haben, von Content-Websites bis zu einer Anwendung mit Konten, Käufen und Zahlungen. Die Fehler darin sind nicht theoretisch: Die meisten haben wir tatsächlich gemacht, oder ein Code-Review hat sie entdeckt.
Die Anleitung gilt für htmx 4 und Symfony 8. Die Kombination mit clientseitigen Bibliotheken wie Alpine.js behandeln wir in einem eigenen Artikel.
So funktioniert htmx
Der Server liefert fertiges HTML und htmx fügt es in die Seite ein. Sie brauchen weder eine API für das Frontend noch eine zweite Schicht für Zustand und Validierung. Templates, Formulare und Sicherheit bleiben in Symfony.
Jede Interaktion durchläuft denselben Zyklus: Auslöser → Anfrage → Antwort → Auswahl eines Teils der Antwort → Austausch im DOM → Abschluss → Eintrag in die History. Attribute bestimmen, was in den einzelnen Schritten passiert. Eine Übersicht darüber, zusammen mit Austauscharten, Headern und Events, finden Sie am Ende des Artikels.
Regeln, die Sie vorab kennen sollten
- Vererbung ist explizit. Nachfahren erhalten nur ein Attribut mit dem Suffix
:inherited. Ohne es werden Links nicht geboostet und es erscheint kein Fehler. - Erweiterungen werden durch den Import aktiviert. Dafür ist kein Attribut nötig.
- Events heißen
htmx:phase:action, zum Beispielhtmx:before:swap. Ein Listener mit einem Tippfehler im Namen läuft stillschweigend nie. - Alles außer 204 und 304 wird eingefügt, auch Fehlerseiten.
- Die History speichert keine Kopien von Seiten. Ein Schritt zurück lädt die Seite erneut vom Server.
- Morph ist Teil des Kerns. Es braucht keine Erweiterung.
Installation
npm install htmx.org@4
Die Einstiegsdatei funktioniert mit Vite über Symfony Reprise genauso wie mit Webpack Encore:
import htmx from 'htmx.org'
// Erweiterungen registrieren sich am globalen htmx, deshalb nach dem Kern importieren.
import 'htmx.org/dist/ext/hx-preload.js'
import 'htmx.org/dist/ext/hx-head.js'
import registerScrollRestore from './js/scroll-restore.js'
htmx.config.transitions = !matchMedia('(prefers-reduced-motion: reduce)').matches
registerScrollRestore()
htmx.initialize()müssen Sie nicht aufrufen, htmx startet von selbst, sobald das Modul ausgewertet ist. Registrieren Sie Erweiterungen und Listener deshalb direkt im Modul, nicht später.- Skripte gehören in den
<head>. Das Morphen des<body>-Inhalts würde ein Skript am Seitenende wie ein gewöhnliches Element behandeln.
Navigation und Prefetch
Grundeinstellung
Für die meisten Projekte genügt eine Einstellung am <body>:
<body hx-boost:inherited="true"
hx-swap:inherited="innerMorph"
hx-head:inherited="merge">
Controller ändern sich nicht und liefern weiterhin ganze Seiten. Auf diese Weise kann sogar die Suche mit Filtern und Seitennavigation laufen, als gewöhnliche GET-Formulare und Links, ohne ein einziges Fragment und ohne dass der Server Header auswertet.
hx-swap:inherited gelangt auch zu Elementen mit eigenem hx-get oder hx-post. Diese sollten deshalb immer ein eigenes hx-swap haben.
Die zweite Variante verwenden Sie, wenn auf der Seite etwas außerhalb des Austauschs bleiben muss, zum Beispiel von einem fremden Skript eingefügte Elemente. Das Morphen des ganzen <body> würde sie entfernen, weil sie nicht in der Antwort stehen.
<head hx-head="merge">
<body hx-boost:inherited="target:'#app', select:'#app', swap:'outerMorph ignoreTitle:true'">
<div id="app" hx-history-elt>
hx-head="merge"ist hier notwendig. Wenn das Ziel nicht der<body>ist, ergänzthx-headden Head nur, und Titel und Meta-Tags häufen sich an.- Geben Sie das Ziel als Selektor an, nicht als
target:this. - Setzen Sie
hx-targetundhx-selectnicht auf#app, sonst erben sie alle Nachfahren. - Ohne
hx-history-eltwürde ein Schritt zurück den ganzen<body>austauschen.
Was Boost übernimmt und was Sie ausnehmen sollten
Boost gilt für Links und Formulare mit demselben Origin. Ausgelassen werden Links mit download, mit einem anderen target als _self und Links, die mit # beginnen. Einen Klick mit Strg, Cmd oder Umschalt überlässt es dem Browser.
Setzen Sie hx-boost="false" auf:
- Links zur Administration oder zu einem anderen Bereich mit eigenen Assets,
- Seiten mit einem völlig anderen Layout, zum Beispiel eine Anmeldung ohne Kopf- und Fußzeile,
- das Anmeldeformular und Links mit einem Nebeneffekt,
- einen Filterschalter, der seinen Zustand im Browser verwaltet.
Morphen zwischen unterschiedlichen Layouts ist ein unnötiges Risiko. Ein vollständiges Laden ist einfacher und zuverlässiger.
Morph und die Identität von Elementen
Morph ordnet Elemente nach Tag und id zu. Ein Element ohne id wird mit jedem Element mit demselben Tag an derselben Stelle zusammengeführt. Ein Element mit id wird nur mit einem Element mit derselben id zusammengeführt, sonst wird es neu eingefügt und das alte verschwindet. Deshalb kommt es auf die id des Hauptcontainers der Seite an.
- Vergeben Sie dieselbe
idnur an Seiten desselben Typs, zum Beispiel an zwei Artikeldetails. Dort ist das Zusammenführen sinnvoll und der Zustand bleibt erhalten. Seiten mit anderer Struktur sollten eine andereidhaben. Ein einfacher Ersatz ist eineidnach Route, zwei Routen mit gleicher Struktur verlieren dadurch nur das Zusammenführen. - Die
idgehört auf den Hauptcontainer, nicht auf den<body>.innerMorphführt nur die Kinder zusammen und liest oder ändert daher wederidnoch Klasse des<body>. Ein Container ohneidverhält sich genauso wie ein Container mit gemeinsamerid. - Das Zusammenführen überträgt Zustand zwischen unzusammenhängenden Elementen. Bei gemeinsamer Container-
iderscheint Text aus einem Feldsearchauf der nächsten Seite in einem Feldemail, und ein Passwort erscheint lesbar in einem Textfeld, weil Morph dentypeändert, den Wert aber behält. Ein direkt am Element hängender Handler der Schaltfläche „Löschen“ läuft beim Klick auf die neue Schaltfläche „Teilen“. - Formulare sollten eine eigene
idhaben. Bei unterschiedlichen Formular-ids wird der Wert auch unter einem gemeinsamen Container nicht übertragen. Beim Anmeldeformular ist das Pflicht. - Hängen Sie Listener per Delegation an
document, oder entfernen Sie sie vor dem Austausch. - Ein Element, das den Übergang überleben soll, hat auf beiden Seiten dieselbe
id. Es wird samt Wert übertragen, auch unter Containern mit unterschiedlichenids. - Morph entfernt Attribute, die nicht in der Antwort stehen. Ein im Browser gesetztes Kennzeichen wie
data-map-initializedverschwindet und das Widget wird ein zweites Mal initialisiert. Ergänzen Sie es inhtmx.config.morphIgnore(das Standard-Array erweitern, nicht überschreiben), oder fragen Sie den Zustand bei der Bibliothek ab, zum BeispielChart.getChart(canvas). - Morph behält Feldwerte. Zum Zurücksetzen eines Formulars eignet es sich nicht, siehe den Abschnitt über Formulare.
- Attributnamen müssen
setAttribute()passieren. Morph kopiert Attribute damit, und Browser mit älterer Namensprüfung, zum Beispiel WebKit, lehnen einen Namen wie@clickab, die Kurzform aus Alpine.js. Das Zusammenführen bricht auf halbem Weg ab, auf der Seite bleibt eine Mischung aus neuem und altem Inhalt, und htmx verschluckt die Ausnahme, sodass in der Konsole nichts erscheint. Der HTML-Parser akzeptiert ein solches Attribut, in Chrome funktioniert also alles. Verwenden Sie die volle Formx-on:click.
Ein Element nehmen Sie mit dem Attribut hx-morph-skip aus. hx-morph-skip-children aktualisiert nur seine Attribute.
Seitenkopf und Sprache
Die Erweiterung hx-head führt den <head> nach exakter Übereinstimmung der Tags zusammen. Titel, Beschreibung, canonical und JSON-LD werden so nach dem Übergang ausgetauscht.
- Markieren Sie Analyse- und andere einmalige Skripte im Head mit
hx-preserve="true", sonst würden sie bei jedem Übergang gelöscht und neu ausgeführt. <html lang>ändert sich nicht, weil htmx nur den Inhalt des<body>austauscht. Nach dem Sprachwechsel bleibt für Screenreader und Übersetzer die alte Sprache. Geben Sie dem Umschalterhx-boost="false", oder setzen Sielangaus der Antwort:
document.addEventListener('htmx:before:swap', (event) => {
const lang = event.detail.ctx.text?.match(/<html[^>]*\slang="([^"]*)"/i)?.[1]
if (lang) document.documentElement.lang = lang
})
- htmx schreibt den Titel erst nach dem Austausch. Bei aktivierten View Transitions kommt das Schreiben erst nach Ende des Übergangs, und ein zweiter schneller Klick kann dann im Tab den Titel der ersten Seite hinterlassen.
ignoreTitle:trueinhx-swapüberlässt den Titel alleinhx-head, das ihn sofort setzt. - Laden Sie Styles nicht aus lazy importiertem JavaScript. Vite fügt sie in den
<head>ein, der nächste Übergang entfernt sie als unbekannt, und sie werden nicht erneut hinzugefügt. Laden Sie nur JavaScript lazy.
Prefetch
Die Erweiterung hx-preload lädt die Seite bereits bei mousedown oder touchstart. Beim Loslassen der Taste ist der Inhalt unterwegs oder fertig. Für geboostete Links genügt es, die Erweiterung zu laden, das Attribut ist nur nötig, um das Verhalten zu ändern:
<body hx-preload:inherited="mousedown, touchstart">
Ein Prefetch ist eine echte Anfrage und nutzt nicht den HTTP-Cache, Sie müssen dafür also kein Cache-Control setzen. Er betrifft nur GET. Daraus folgt:
- Ein GET mit Nebeneffekt muss
hx-boost="false"haben. Prefetch unterscheidet keine Maustasten. Ein Abmeldelink per GET würde die Person schon beim Rechtsklick oder beim Beginn des Scrollens auf dem Handy abmelden. Das gilt für alles, was ein GET ändert, zum Beispiel das Abholen einer Flash-Nachricht. - Aufrufzähler zählen leicht zu viel, weil auch ein abgebrochener Klick zählt. Filtern Sie Prefetches nicht auf dem Server: Nach dem Klick ist der Prefetch meist die einzige Anfrage, sodass fast alle echten Aufrufe verschwinden würden. In einer Liste, in der die Scrollgeste oft auf einem Link beginnt, schränken Sie die Auslöser mit
hx-preload:inherited="mousedown"ein. - Verlängern Sie die Gültigkeit nicht, wo persönlicher Zustand im Spiel ist. Die voreingestellten 5 Sekunden sind sinnvoll. Morph behält einen Link ohne
idsamt geladener Antwort, eine längere Gültigkeit würde nach einem Kauf also ein altes Guthaben anzeigen. Auf einer reinen Content-Website lässt sie sich verlängern, zum Beispielmousedown timeout:30s. - Das Auslösen beim Überfahren mit der Maus haben wir verworfen. Die Erweiterung hat keine Verzögerung, das Überfahren eines Menüs rendert also alle Seiten dahinter. Eine eigene Verzögerung führte zu doppelten Anfragen und brachte gegenüber
mousedownnur einige Dutzend Millisekunden.
Übergänge zwischen Seiten
htmx.config.transitions = true schaltet View Transitions ein. Das kostet Zeit: Mit der Standarddauer haben wir etwa 300 ms pro Übergang gemessen, wodurch eine Website mit Prefetch langsamer wirkt, als sie ist. Kürzen Sie alle drei Teile, die Länge des gesamten Übergangs bestimmt die Gruppe:
::view-transition-group(root),
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 100ms;
}
- Bei reduzierter Bewegung schalten Sie Übergänge gar nicht erst ein. Schon der Schnappschuss friert die Seite für die Dauer des Übergangs ein, auch wenn die Animation in CSS ausgeschaltet ist.
- Derselbe
view-transition-namean zwei Elementen bricht den Übergang ab. - Verwenden Sie kein globales
scroll-behavior: smooth. Die Seite würde nach jedem Übergang und jedem Schritt zurück sichtbar scrollen. Schalten Sie weiches Scrollen nur für bestimmte Links ein.
Fehlerseiten
htmx fügt auch eine Fehlerantwort ein, ein geboosteter Link auf eine nicht vorhandene Seite zeigt also Ihre 404-Seite.
- Templates in
templates/bundles/TwigBundle/Exception/brauchen das vollständige Layout. - In den Umgebungen
devundtestwerden sie nicht gerendert, weil dortkernel.debugaktiv ist. Probieren Sie sie auf einemprod-Build aus. - Bei der Variante mit
#appwürde eine Antwort ohne#app(502 vom Gateway während eines Deployments, ein fataler PHP-Fehler) den Inhalt löschen und eine leere Seite hinterlassen. Es hilft eine Absicherung, die die Navigation an den Browser übergibt:
document.addEventListener('htmx:before:swap', (event) => {
const { ctx, tasks } = event.detail
if (ctx.target?.id !== 'app') return
const main = tasks.find((task) => task.type === 'main')
if (main && main.fragment.childElementCount === 0) {
event.preventDefault()
window.location.assign(ctx.request.action)
}
})
Abgelaufene Anmeldung
Wenn eine Anmeldung abläuft, leitet Symfony zur Anmeldeseite weiter und fetch folgt der Weiterleitung von selbst. Was danach passiert, hängt von der Anfrage und vom Layout der Anmeldeseite ab:
- Geboosteter Link, Anmeldung im selben Layout wie die übrige Website: funktioniert ohne Änderungen. Die Anmeldeseite wird wie jede andere zusammengeführt und ihre Adresse in die History geschrieben.
- Geboosteter Link, Anmeldung mit anderem Layout: Morphen zwischen unterschiedlichen Seiten ist ein unnötiges Risiko, und bei der Variante mit
#apphat die Anmeldeseite dieses Element womöglich gar nicht. - Fragment mit eigenem Ziel: Die Anmeldeseite wird in das Ziel eingefügt, egal welches Layout sie hat.
In den letzten beiden Fällen hilft es, wenn AuthenticationEntryPoint bei htmx ein vollständiges Laden erzwingt:
public function start(Request $request, ?AuthenticationException $authException = null): Response
{
$loginUrl = $this->urlGenerator->generate('app_login');
if ($request->headers->has('HX-Request')) {
return new Response('', Response::HTTP_OK, ['HX-Redirect' => $loginUrl]);
}
return new RedirectResponse($loginUrl);
}
Vor diesem Aufruf speichert Symfony die Adresse der Anfrage als Ziel nach der Anmeldung und betrachtet eine htmx-Anfrage nicht als XHR. Aus dem Code des Frameworks folgt, dass die Person nach einem per GET angeforderten Fragment nach der Anmeldung beim nackten Fragment landen würde. Wenn Sie Fragmente hinter einer Anmeldung haben, entfernen Sie in diesem Zweig das gespeicherte Ziel mit TargetPathTrait::removeTargetPath().
Zurück und vorwärts
Hier weicht htmx 4 am stärksten von der üblichen Vorstellung ab. Es speichert beim Übergang keine Kopie der verlassenen Seite, ein Schritt zurück muss sie also erneut vom Server laden. Der Browser übernimmt das nicht: Sein Back/Forward-Cache funktioniert nur beim Wechsel zwischen Dokumenten, und ein geboosteter Übergang ändert das Dokument nicht.
Was passiert
- In einem Browser mit Navigation API unterbricht htmx die Navigation, lädt die Seite und tauscht sie aus. In Firefox und in Browsern ohne diese API startet
popstatedieselbe Arbeit. - Die Anfrage trägt nur
HX-History-Restore-Request. Die HeaderHX-RequestundHX-Boostedfehlen, der Server liefert also ohne jede Anpassung die ganze Seite. - Der Austausch läuft über
outerSync, nicht über Morph. Die Kinder werden ersetzt, Komponentenzustand und angefangene Felder darin gehen also verloren. hx-headführt bei der Wiederherstellung den Head aus der Antwort zusammen, der Titel stimmt also.
Was daraus folgt
- Zustand, der einen Schritt zurück überleben soll, gehört in die Adresse. Filter, Seite oder Suche müssen in der URL stehen. Ein Fragment mit
hx-push-urlmuss eine Adresse haben, unter der der Server die ganze Seite liefert. - Die Daten sind nach der Rückkehr aktuell, aber das Bedienen eines Schritts zurück ist eine gewöhnliche Anfrage, die Geschwindigkeit hängt also vom Server ab. Die Erweiterung
hx-history-cacheändert dieses Verhalten, damit haben wir aber keine Erfahrung. - Widgets müssen sich zerstören und neu erstellen lassen. Zerstören Sie Diagramme in
htmx:before:swapund erstellen Sie sie inhtmx.onLoad. - Das Aufräumen von Zustand außerhalb des Austauschs gehört ebenfalls in
htmx:before:swap, zum Beispiel das Entfernen einer Klasse, die bei geöffnetem Menü das Scrollen sperrt.
Scrollposition
Der Browser stellt die Scrollposition erst nach dem Ende der unterbrochenen Navigation wieder her. Die Seite wird deshalb zuerst oben gerendert und springt dann nach unten. Firefox macht es umgekehrt: Er scrollt noch den alten Inhalt, und nach dem Austausch wird die Position beim Wechsel von einer längeren auf eine kürzere Seite abgeschnitten. Die Lösung ist, sich die Position selbst nach dem Schlüssel des History-Eintrags zu merken:
const STORAGE_KEY = 'scroll-positions'
const LIMIT = 50
function load() {
try {
return JSON.parse(sessionStorage.getItem(STORAGE_KEY)) || {}
} catch (e) {
return {}
}
}
function store(positions) {
const keys = Object.keys(positions)
for (const key of keys.slice(0, Math.max(0, keys.length - LIMIT))) delete positions[key]
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(positions))
} catch (e) { /* privates Fenster: die Position bleibt nur bis zum Neuladen erhalten */ }
}
export default function registerScrollRestore() {
if (!window.navigation) return
const positions = load()
let pending = null
// Dieselbe Bedingung, unter der htmx auf popstate hört, statt die Navigation zu unterbrechen.
if (/firefox/i.test(navigator.userAgent)) {
history.scrollRestoration = 'manual'
const isHistoryLoad = performance.getEntriesByType('navigation')[0]?.type === 'back_forward'
const target = positions[navigation.currentEntry.key]
if (isHistoryLoad && target !== undefined) window.scrollTo(0, target)
// Das Verlassen über die Adressleiste löst das Event navigate nicht aus.
window.addEventListener('pagehide', () => {
positions[navigation.currentEntry.key] = window.scrollY
store(positions)
})
}
// Muss vor dem htmx-Listener registriert werden, sonst kommt scroll: 'manual' zu spät.
navigation.addEventListener('navigate', (event) => {
positions[navigation.currentEntry.key] = window.scrollY
store(positions)
const isHistoryRestore = event.navigationType === 'traverse' && event.canIntercept && !event.hashChange
// Einträge mit derselben Adresse stammen vom modalen Fenster: Die Seite ändert sich nicht und es folgt kein Austausch.
if (isHistoryRestore && event.destination.url === navigation.currentEntry.url) {
event.intercept({ scroll: 'manual' })
return
}
const target = positions[event.destination.key]
if (!isHistoryRestore || target === undefined) return
pending = target
event.intercept({ scroll: 'manual' })
})
const restore = () => {
if (pending === null) return
window.scrollTo(0, pending)
pending = null
}
// Mit einer View Transition kommt htmx:after:swap erst nach dem Übergang. Das Scrollen
// gehört deshalb ans Ende seiner Aufgabe, damit schon der neue Schnappschuss richtig steht.
document.addEventListener('htmx:before:viewTransition', (event) => {
if (pending === null) return
const task = event.detail.task
event.detail.task = async () => {
await task()
restore()
}
})
document.addEventListener('htmx:after:swap', restore)
}
Browser ohne Navigation API lässt das Modul aus. Dort stellt der Browser die Position selbst wieder her, mit dem beschriebenen Sprung.
Modale Fenster
Man erwartet, dass Zurück ein offenes Fenster schließt. htmx würde stattdessen die Seite erneut laden. Ein Fenster fügt der History deshalb einen eigenen Eintrag mit derselben Adresse hinzu, und den Zustand aller Fenster hält eine einzige, mit der History verbundene Stelle:
let entryPath = null
const modal = {
current: null,
open(name) {
history.pushState({ modal: name }, '')
entryPath = location.pathname + location.search
this.current = name
},
}
// Ein Schritt zurück vom Fenstereintrag schließt nur das Fenster, die Seite wird nicht neu geladen.
document.addEventListener('htmx:before:history:restore', (event) => {
const isLeavingEntry = entryPath === event.detail.path
entryPath = null
modal.current = null
if (isLeavingEntry) event.preventDefault()
})
// Vorwärts öffnet das Fenster wieder. htmx lässt einen solchen Eintrag in Ruhe, weil es darin keinen eigenen Zustand findet.
window.addEventListener('popstate', (event) => {
if (!event.state?.modal) return
entryPath = location.pathname + location.search
modal.current = event.state.modal
})
// Ein Link aus dem Fenster: Die neue Seite ersetzt den Fenstereintrag, sonst würde Zurück nur die Adresse wiederherstellen.
document.addEventListener('htmx:before:history:update', (event) => {
if (entryPath !== null) event.detail.history.type = 'replace'
entryPath = null
modal.current = null
})
- Das Schließen über das Kreuz oder Escape ruft
history.back()auf, damit der Eintrag nicht in der History bleibt. - Nach einem Schritt in der History setzt die Navigation API den Fokus auf den
<body>. Geben Sie den Fokus deshalb erst innavigatesuccessan die ursprüngliche Schaltfläche zurück, in Firefox nachpopstate. - Verwenden Sie für das Fenster den nativen
<dialog>undshowModal(). Die modale Ebene, Escape und das Festhalten des Fokus bekommen Sie vom Browser.
Formulare und Validierung
Controller
Ein geboosteter Link sendet ebenfalls HX-Request. Wer nur diesen Header prüft, schickt dem Link ein Fragment statt einer Seite. Die Hilfsmethode prüft deshalb beide:
private function isFragmentRequest(Request $request): bool
{
return $request->headers->has('HX-Request')
&& !$request->headers->has('HX-Boosted');
}
#[Route('/kontakt', name: 'app_contact', methods: ['GET', 'POST'])]
public function contact(Request $request): Response
{
$form = $this->createForm(ContactType::class);
$form->handleRequest($request);
$isFragment = $this->isFragmentRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
// Speichern und senden.
return $isFragment
? $this->render('contact/_sent.html.twig')
: $this->redirectToRoute('app_contact');
}
return $this->render(
$isFragment ? 'contact/_form.html.twig' : 'contact/index.html.twig',
['form' => $form],
);
}
AbstractController::render() setzt den Status 422 nur, wenn Sie ein FormInterface übergeben. Mit $form->createView() bleibt es bei 200 und Sie müssen den Status selbst setzen.
Template
{{ form_start(form, {
action: path('app_contact'),
attr: {
id: 'contact-form',
novalidate: true,
'hx-post': path('app_contact'),
'hx-swap': 'outerHTML',
'hx-sync': 'this:drop',
},
}) }}
- Behalten Sie
actionundmethod, ohne JavaScript wird das Formular normal abgeschickt. hx-swap="outerHTML"und kein Morph. Morph behält Feldwerte, das Formular bliebe nach dem Absenden also ausgefüllt.hx-sync="this:drop"verwirft ein zweites Absenden.novalidate, wenn der Server die Fehler prüft. Firefox für Android zeigt die Meldungen des Browsers nicht an, ein leeres Formular würde nach dem Klick also stillschweigend stehen bleiben. Das Attributrequiredan den Feldern behalten Sie für Screenreader.
Fehlerantworten
Eine Validierungs-422 wird ohne Konfiguration eingefügt. Bei einem Fragment wollen Sie aber keine ganze Fehlerseite in das Formular einfügen. hx-status sucht zuerst den genauen Code und erst dann einen Platzhalter, 422 muss also eigens aufgeführt sein. Sonst verschwinden Validierungsfehler stillschweigend:
<form hx-post="…" hx-target="#modal-content" hx-swap="innerHTML"
hx-status:422="swap:'innerHTML'"
hx-status:4xx="swap:'none'"
hx-status:5xx="swap:'none'">
swap:'none' reicht nicht. hx-head führt den Head der Antwort noch vor der Auswertung von hx-status zusammen, und htmx setzt den Titel, auch wenn nichts ausgetauscht wird. Eine Fehlerseite würde so Titel und Head der aktuellen Seite überschreiben. Erlauben Sie Head und Titel deshalb nur beim Übergang auf eine ganze Seite. Bei der Variante mit #app vergleichen Sie das Ziel mit diesem Element:
const isFullPageSwap = (ctx) => ctx.target === document.body
document.addEventListener('htmx:head:before:merge', (event) => {
if (!isFullPageSwap(event.detail.ctx)) event.preventDefault()
})
document.addEventListener('htmx:before:swap', (event) => {
if (!isFullPageSwap(event.detail.ctx)) event.detail.ctx.title = null
})
Ein Fehler darf keinen Text verwerfen. Bei einem Formular, in dem jemand lange geschrieben hat, verwerfen Sie die 5xx-Antwort und fügen unter der Schaltfläche Telefon und E-Mail ein. Dasselbe tun Sie bei einem Netzwerkausfall:
document.addEventListener('htmx:before:response', (event) => {
if (event.detail.ctx.response.status < 500) return
event.preventDefault()
event.target.addEventListener('htmx:finally:request', () => showFailure(event.target), { once: true })
})
document.addEventListener('htmx:error', (event) => {
event.target.addEventListener('htmx:finally:request', () => showFailure(event.target), { once: true })
})
Weiterleitungen
- Ein Ziel außerhalb der aktuellen Seite (Zahlungs-Gateway, anderes Layout): Geben Sie 200 mit
HX-Redirectzurück. Bei einer 3xx-Antwort verarbeitet htmx keine Header, undfetchfolgt der Weiterleitung von selbst. hx-postmit einer gewöhnlichen Weiterleitung fügt die ganze Zielseite inhx-targetein. Eine ganze Seite in einem kleinen Rahmen ist das typische Symptom.- Ein geboostetes Formular bewältigt eine Weiterleitung und schreibt die endgültige Adresse in die History. Hat das Ziel aber ein anderes Layout, wählen Sie
hx-boost="false". Das gilt vor allem für die Anmeldung, bei der der Authenticator nach dem Erfolg weiterleitet.
Formulare in modalen Fenstern
Rendern Sie ein Formular im Fenster direkt in die Seite, laden Sie es nicht lazy. So vermeiden Sie Probleme mit dem Timing und mit der Verarbeitung versteckter Elemente. Der Preis ist, dass das CSRF-Token auf einer lange geöffneten Seite ablaufen kann, bereiten Sie für diesen Fall also eine verständliche Meldung vor. In Symfony setzen Sie sie über csrf_message und übersetzen sie in der Domain validators.
Barrierefreiheit
- Nach einem Austausch liegt der Fokus oft auf dem
<body>. Setzen Sie ihn nach einer 422 auf das erste fehlerhafte Feld, nach dem Absenden auf die gesamte Bestätigung. Beides inhtmx:after:settle. - Verwenden Sie beim Absenden an der Schaltfläche
aria-disabled, nichtdisabled, das den Fokus an den Seitenanfang werfen würde. - Melden Sie den Sendestatus in einem vorbereiteten Element mit
role="status".
Laden von Seitenteilen
Wählen Sie in dieser Reihenfolge und beginnen Sie nicht mit dem Letzten:
- Die ganze Seite per Boost. Kein Code auf dem Server. Reicht für die meisten Listen, Seitennavigationen und Filter.
- Eine eigene Route für das Fragment. Eine Adresse, eine Darstellung. Geeignet für Lazy Loading.
- Dieselbe Adresse, zwei Darstellungen. Nur wenn die ganze Seite wirklich zu teuer ist.
Lazy Loading
Es lohnt sich bei Inhalten, die groß oder aufwendig in der Erstellung sind und die die meisten Menschen nie öffnen. Typischerweise ein Preisdiagramm, eine Bestellhistorie oder eine Dokumentvorschau in einem eingeklappten Abschnitt. Die Seite wird so nicht durch etwas aufgehalten, das die meisten Besucher nicht brauchen.
<details>
<summary>Preisentwicklung</summary>
<div hx-get="{{ path('app_product_price_history', {id: product.id}) }}"
hx-trigger="toggle once from:'closest details'"
hx-swap="innerHTML"></div>
</details>
#[Route('/produkt/{id}/preisentwicklung', name: 'app_product_price_history', requirements: ['id' => '\d+'], methods: ['GET'])]
public function priceHistory(#[MapEntity(id: 'id')] Product $product): Response
{
$response = $this->render('product/_price_history.html.twig', ['product' => $product]);
$response->setPublic();
$response->setMaxAge(3600);
$response->headers->set('X-Robots-Tag', 'noindex');
return $response;
}
- Ein Selektor aus mehreren Wörtern in
hx-triggermuss in Anführungszeichen stehen. Klammern versteht htmx 4 nicht, und ein Fehler bei der Verarbeitung stoppt die Verarbeitung der übrigen Seite. - Wenn ein Wert aus der Adresse ein Template oder eine Datei bestimmt, schränken Sie ihn mit
requirementsein, damit sich darüber nichts anderes laden lässt. - Reservieren Sie Platz für den Inhalt, damit sich die Seite nach dem Laden nicht verschiebt.
- Ein Fragment ohne personenbezogene Daten können Sie als öffentlich cachen.
Seitennavigation mit Fragment
<a href="{{ path('app_blog', {page: page + 1}) }}"
hx-get="{{ path('app_blog', {page: page + 1}) }}"
hx-target="#articles"
hx-swap="innerHTML"
hx-push-url="true">Weiter</a>
Der Link braucht href und hx-get. Ohne href findet ihn kein Crawler, und ohne JavaScript funktioniert er auch nicht. Der Controller entscheidet über isFragmentRequest():
$response = $this->render(
$this->isFragmentRequest($request) ? 'blog/_grid.html.twig' : 'blog/index.html.twig',
['articles' => $articles, 'page' => $page],
);
$response->setVary(['HX-Request', 'HX-Boosted'], false);
return $response;
Vary schützt davor, dass ein Cache ein Fragment statt der ganzen Seite liefert. Wer dasselbe Ergebnis über hx-select aus der ganzen Seite erhält, hat eine Adresse mit einer einzigen Darstellung, und der Server muss nichts unterscheiden. Der Preis ist die Übertragung einer größeren Antwort.
Aktualisierungen außerhalb des Ziels
hx-swap-oob aktualisiert mit einer Antwort weitere Stellen, zum Beispiel die Anzahl im Warenkorb oder eine Meldung zum Rabattcode.
- Wenn nach dem Herauslösen der OOB-Elemente in der Antwort nichts übrig bleibt, überspringt htmx den Hauptaustausch. Eine leere Antwort löscht daher einen alten Fehler nicht. Es hilft
hx-swap="innerHTML swapEmpty:true". - Packen Sie Tabellenzeilen und Listeneinträge in ein
<template>, sonst verwirft sie der HTML-Parser.
JavaScript in geladenen Inhalten
- Initialisieren Sie Widgets in
htmx.onLoad(callback). Das läuft beim ersten Laden und nach jedem Austausch, auch beim Schritt zurück. htmx:after:swapwird an dem Element ausgelöst, das die Anfrage gesendet hat, nicht am Ziel. Für die Initialisierung neuer Inhalte eignet es sich nicht.- Die Initialisierung muss wiederholbar sein.
- Laden Sie schwere Bibliotheken über
import(), erst wenn ihr Element auf der Seite ist.
SEO
htmx ist für Suchmaschinen unsichtbar, solange eine einzige Regel gilt: Jede Adresse liefert ohne JavaScript die ganze Seite. Ein Crawler sendet kein HX-Request und bekommt also das, was ein Mensch beim ersten Laden bekommt.
curl -s https://example.com/leistungen | grep -E '<title>|rel="canonical"|<h1'
curl -sI https://example.com/gibt-es-nicht | head -1
Regeln
- Links sind
<a href>. Ein Crawler folgt keinemhx-getan einer Schaltfläche oder einem<div>. - Was gefunden werden soll, steht im ersten HTML. Ein Crawler klickt nicht, scrollt nicht und öffnet keinen Abschnitt. Laden Sie über htmx nur Ergänzungen, wie ein Diagramm oder eine Historie. Text, der gefunden werden soll, lassen Sie im HTML, auch in einem eingeklappten
<details>. Viele KI-Crawler führen gar kein JavaScript aus. - Titel, Beschreibung,
canonicalundhreflangstehen in der Antwort jeder Adresse.hx-headtauscht sie dann auch für den Nutzer aus. - Echte Statuscodes. Eine 404 muss eine 404 sein und ein Umzug eine 301. Eine Fehlerseite mit Status 200 käme in den Index.
- Jede Seite einer Liste hat ein eigenes
canonical. Die zweite Seite hat eine Adresse mit?page=und einen Titel mit der Nummer. Google rät ausdrücklich davon ab, die erste Seite als kanonisch zu verwenden. Weitere Seiten müssen Sie nicht in die Sitemap aufnehmen, zu ihnen führen Links. hreflangauf weiteren Seiten einer Liste hängt vom Inhalt ab. Wenn die Sprachversionen dieselben Einträge in derselben Reihenfolge haben und sich nur in der Übersetzung unterscheiden, verweisen Sie in jeder Sprache auf dieselbe Seite. Wenn sich die Anzahl der Einträge unterscheidet, lassen Siehreflangauf weiteren Seiten weg, denn ein Link auf eine nicht vorhandene Seite ist schlechter als keiner.- Gefilterte und sortierte Varianten einer Liste, die keine Einstiegsseiten sein sollen, schließen Sie mit
noindexvom Index aus, wie Google es empfiehlt. Eine kanonische Adresse auf die Liste ohne Parameter ist für die Suchmaschine nur ein Hinweis, den sie bei einer Seite mit anderem Inhalt nicht übernehmen muss. - Die Seitennavigation hat echte Links. Endloses Nachladen ohne Adressen ist für Crawler eine Sackgasse.
- Fragmente gehören nicht in den Index. Eigene Routen bekommen
X-Robots-Tag: noindexund stehen nicht in der Sitemap. Sperren Sie sie nicht in derrobots.txt, sonst würde der Crawler den Header nicht lesen. - POST-Routen von Formularen und Suchergebnisse haben in der Sitemap nichts zu suchen.
Geschwindigkeit
Prefetch und Morph beschleunigen Übergänge, aber die Core Web Vitals in der Search Console beruhen vor allem auf dem ersten Laden. htmx ist klein, achten Sie trotzdem auf das Bundle und laden Sie schwere Bibliotheken lazy. Beobachten Sie auch die Antwortzeit des Servers, denn Prefetch und der Schritt zurück laufen über ihn.
Messung
Ein htmx-Übergang ist kein Seitenaufruf, die Analyse zählt die Aufrufe also nicht von selbst.
- Senden Sie Ihre eigene Messung nach
htmx:after:swap, nur wenn sich die Adresse geändert hat. Ein geöffnetes Fenster oder ein abgeschicktes Formular zählt dann nicht als Aufruf. - Der Titel ist zu diesem Zeitpunkt neu, weil
hx-headihn schon beim Eintreffen der Antwort zusammengeführt hat. Ein Event nach dem Schreiben in die History kommt noch vor dem Austausch des Inhalts, die Seite zeigt in diesem Moment also den alten Inhalt. - Google Tag Manager bewältigt Übergänge über den Trigger History Change. Wenn Sie zusätzlich selbst senden, haben Sie zwei Aufrufe.
- Ein Schritt zurück zählt als Aufruf. Vergewissern Sie sich, dass Sie das so wollen.
- Markieren Sie das Messskript mit
hx-preserve="true".
Tests und Deployment
- Probieren Sie vor jedem htmx-Update einen Übergang und ein Absenden des Formulars auch in einem älteren Browser aus sowie einen Schritt zurück aus einem modalen Fenster auf einer frisch geladenen Seite. Gehen Sie auch die Vorkommen von
htmx:in den Templates durch. - In End-to-End-Tests warten Sie auf
htmx:finally:requestund hängen den Listener im selben Skript vor dem Klick an. Das Warten auf das Verschwinden der Klasse.htmx-requestdirekt nach dem Klick kann enden, bevor die Klasse überhaupt erscheint. Laden Sie nach dem Austausch den DOM im Test-Client neu. - Testen Sie den Schritt zurück in Chrome und Firefox, dort läuft er durch anderen Code. Probieren Sie es mit Übergängen und mit reduzierter Bewegung.
- Probieren Sie Übergänge auch in WebKit aus, nicht nur in Chrome. Ein Morph-Fehler gibt dort unter Umständen nichts aus, Sie erkennen ihn daran, dass nach einem Klick ein Teil der alten Seite stehen bleibt. Unter Linux eignen sich dafür GNOME Web oder
MiniBrowseraus WebKitGTK. - Testen Sie Fehlerseiten auf
prodund probieren Sie einmal auch einen Ausfall ohne Ihr Template aus, zum Beispiel eine 502 vom Proxy. - Vorsicht bei einer offenen Seite während des Deployments.
hx-headvergleicht Tags wörtlich. Aus dem htmx-Code folgt, dass ein Skript mit neuem Hash beim nächsten Übergang hinzugefügt wird und neben dem alten läuft. Im Betrieb haben wir das noch nicht gemessen, nach einem Deployment mit größerer Änderung lohnt sich aber eine Kontrolle.
Checkliste für ein neues Projekt
- Die Erweiterungen
hx-headundhx-preloadnach dem Kern importiert <body>mithx-boost:inheritedund Morph, oder#appmithx-head="merge"undhx-history-elt- Alle Skripte im
<head>, einmalige mithx-preserve="true" hx-boost="false"für Abmeldung, Anmeldung, Administration, andere Layouts und GET mit Nebeneffekt<html lang>passt nach dem Sprachwechsel zur Seite- Fragment nur bei
HX-Request && !HX-Boosted, bei derselben Adresse mitVary AuthenticationEntryPointgibt bei htmxHX-Redirectzurück, wenn die Anmeldung ein anderes Layout hat oder Fragmente hinter einer Anmeldung liegen- Fehlertemplates mit vollständigem Layout, bei
#appeine Absicherung gegen eine leere Auswahl - Übergänge gekürzt und bei reduzierter Bewegung aus, kein globales
scroll-behavior: smooth - Modul zur Wiederherstellung der Scrollposition vor dem Start von htmx registriert
- Zustand modaler Fenster an einer Stelle, mit der History verbunden
- Zustand, der Zurück überleben soll, steht in der Adresse
- Formulare:
FormInterfaceanrender(),outerHTML,hx-sync,hx-status:422bei einem4xx-Platzhalter, Textschutz bei 5xx, Fokus nach dem Austausch - Widgets in
htmx.onLoad, wiederholbare Initialisierung, clientseitige Kennzeichen inmorphIgnore - Attribute ohne
@im Namen, Übergänge auch in WebKit ausprobiert - SEO:
curlliefert die ganze Seite, Links sind<a href>, Hauptinhalt im ersten HTML, Fragmente mitnoindex - Messung nach
htmx:after:swap, nicht nach einer History-Änderung - Schritt zurück in Chrome und Firefox getestet, Fehlerseiten auf
prod
Wann Sie htmx nicht einsetzen sollten
htmx passt dort, wo der Server den Großteil der Arbeit erledigt: Content-Websites, Formulare, Konten, Administration. Für eine Anwendung, die viel Zustand im Browser hält, zum Beispiel einen Editor oder Offline-Arbeit, ist ein clientseitiges Framework die bessere Wahl.
Übersicht über Attribute, Header und Events
Attribute, die Sie verwenden werden
hx-get,hx-post: senden eine Anfrage an die angegebene Adresse.hx-trigger: bestimmt das Event, das die Anfrage auslöst. Standard ist ein Klick, bei einem Formular das Absenden.hx-target: bestimmt das Element, das der Austausch betrifft. Standard ist das Element selbst.hx-swap: bestimmt die Austauschart. Standard istinnerHTML.hx-select: wählt per Selektor nur einen Teil der Antwort aus.hx-boost: macht aus einem gewöhnlichen Link oder Formular eine htmx-Anfrage.hx-push-url: schreibt die Adresse in die Browser-History.hx-sync: regelt gleichzeitige Anfragen, zum Beispiel das Verwerfen eines zweiten Absendens.hx-status:CODE: ändert das Verhalten je nach Antwortstatus, zum Beispielhx-status:422.hx-swap-oob: markiert in einer Antwort ein Element, das außerhalb des Hauptziels ausgetauscht wird.hx-preserve: lässt ein Element unverändert, wenn es auf der alten und der neuen Seite steht.hx-history-elt: bestimmt das Element, das beim Schritt zurück ausgetauscht wird. Standard ist der<body>.
Austauscharten
innerHTML,outerHTML: ersetzen den Inhalt oder das ganze Element. Sinnvoll, wenn der Zustand verworfen werden soll.innerMorph,outerMorph: führen neues HTML in das bestehende DOM ein und behalten Fokus, Scrollposition, Animationen und Feldwerte.outerSync: führt die Attribute des Elements zusammen und ersetzt seine Kinder. htmx verwendet es beim Schritt zurück.none: fügt nichts ein.
Header
HX-Request: sendet jede gewöhnliche htmx-Anfrage, also auch ein geboosteter Link.HX-Boosted: sendet nur ein geboostetes Element.HX-History-Restore-Request: sendet die Wiederherstellung der Seite beim Schritt zurück.HX-Redirect(in der Antwort): erzwingt ein vollständiges Laden der Seite. Funktioniert nur mit einer 2xx-Antwort.HX-Retarget,HX-Reswap,HX-Push-Url(in der Antwort): damit überschreibt der Server Ziel, Austausch oder Adresse.
Events
Events steigen im DOM auf, es genügt also, Listener an document zu hängen. Die meisten tragen in event.detail.ctx den Kontext der Anfrage (ctx.target, ctx.request, ctx.response, ctx.text). Wo es angegeben ist, bricht event.preventDefault() den weiteren Ablauf ab. Die Listen stehen in der Reihenfolge, in der die Events eintreffen.
Anfrage:
htmx:config:request: Die Anfrage wird noch zusammengestellt. Hier ändern Sie Header, Adresse oder Body. Abbrechbar.htmx:confirm: nur an einem Element mithx-confirm. Für einen eigenen Bestätigungsdialog stattwindow.confirm, der überdetail.issueRequest()oderdetail.dropRequest()fortfährt.htmx:before:request: unmittelbar vor dem Senden. Nützlich für den Zustand der Schaltfläche und die Meldung zum Senden. Abbrechbar.htmx:before:response: Die Header sind da, der Body noch nicht. Durch Abbrechen verwerfen Sie die Antwort. Abbrechbar.htmx:after:request: Der Body ist gelesen undhx-headhat den Head bereits zusammengeführt, der Austausch ist noch nicht erfolgt. Abbrechbar.htmx:response:error: Der Status ist 400 oder höher. Der Austausch läuft trotzdem weiter.htmx:error: Netzwerkausfall oder Ausnahme bei der Verarbeitung.htmx:finally:request: immer zuletzt, auch nach einem Fehler oder Abbruch. Hierher gehört das Aufräumen.
Austausch des Inhalts:
htmx:before:swap: wird an dem Element ausgelöst, das die Anfrage gesendet hat. Die Adresse ist schon neu, der Inhalt noch alt. Indetail.tasksstehen die vorbereiteten Austauschvorgänge. Abbrechbar.htmx:before:viewTransition: nur bei eingeschaltetem Übergang. Überdetail.tasklässt sich Arbeit ergänzen, die noch in den neuen Schnappschuss kommt.htmx:before:settle,htmx:after:settle: werden am Ziel des Austauschs ausgelöst, der neue Inhalt ist im DOM. Nützlich zum Setzen des Fokus.htmx:after:process: htmx hat die Attribute im neuen Inhalt verarbeitet. Darauf bauthtmx.onLoad()auf, das auch beim ersten Laden läuft.htmx:after:viewTransition: Der Übergang ist beendet.htmx:after:swap: wird an dem Element ausgelöst, das die Anfrage gesendet hat. Mit einem Übergang kommt es erst nach dessen Ende, also später alshtmx:after:settle.htmx:finally:swap: Ende des Austauschs, auch nach einem Fehler.htmx:before:cleanup,htmx:after:cleanup: werden an einem Element ausgelöst, das htmx aus dem DOM entfernt.
History (ausgelöst an document):
htmx:before:history:update: Die Adresse wird gleich geschrieben. Überdetail.history.typelässt sichpushinreplaceändern. Abbrechbar.htmx:after:history:push,htmx:after:history:replace,htmx:after:history:update: Die Adresse ist geschrieben. Das kommt noch vor dem Austausch des Inhalts.htmx:before:history:restore: ein Schritt zurück oder vorwärts, indetail.pathsteht die Zieladresse. Durch Abbrechen wird die Seite nicht erneut geladen.
Zusammenführen des Heads aus der Erweiterung hx-head (ausgelöst am <body>):
htmx:head:before:merge: Durch Abbrechen wird der Head aus der Antwort nicht verwendet.htmx:head:before:add,htmx:head:before:remove: betreffen ein einzelnes Tag, durch Abbrechen bleibt es unberührt.htmx:head:after:merge: Der Head ist zusammengeführt.