Zum Inhalt springen
iD-SIGN
Kontakt aufnehmen

Navigation in Symfony mit htmx

Navigation ohne Neuladen der ganzen Seite in Symfony mit htmx 4: Boost und Morph, Prefetch, View Transitions, Error Pages, Zurück mit Wiederherstellung der Scrollposition, Lazy Loading und SEO.

Teil 1/3 16 Min. Lesezeitveröffentlicht 11/2026

In diesem Artikel 12 Kapitel
  1. Prinzipien
  2. Installation
  3. Navigation
  4. Prefetch
  5. View Transitions
  6. Error Pages
  7. Zurück und Scrollposition
  8. Lazy Loading
  9. SEO
  10. Messung
  11. Tests und Deployment
  12. Checkliste

Der erste Teil der Serie behandelt die Navigation: wie man eine Symfony-Website so baut, dass zwischen den Seiten ohne Neuladen der ganzen Seite gewechselt wird, und worauf man dabei achten muss. Formulare stehen im zweiten Teil, Alpine.js im dritten. Gilt für htmx 4.

Prinzipien

Der Server liefert fertiges HTML und htmx fügt es in die Seite ein. Es braucht weder eine API für das Frontend noch eine zweite Schicht für State und Validierung, Templates, Formulare und Security bleiben in Symfony.

Jede Interaktion läuft gleich ab: Trigger → Request → Response → Auswahl eines Teils der Antwort (hx-select) → Swap in den DOM → Settle → Eintrag in die History. Attribute bestimmen, was in den einzelnen Schritten passiert.

Vorab gut zu wissen:

  • Vererbung ist explizit. An Kindelemente wird nur ein Attribut mit dem Suffix :inherited weitergegeben. Ohne das werden Links nicht geboostet, und es erscheint kein Fehler.
  • Extensions werden per Import aktiviert, ein Attribut braucht es dafür nicht.
  • Events haben die Form htmx:phase:aktion, zum Beispiel htmx:before:swap. Ein Listener mit Tippfehler im Namen wird stillschweigend nie ausgelöst.
  • Geswappt wird alles außer 204 und 304, auch Error Pages.
  • Die History speichert keine Kopien der Seiten. Zurück lädt die Seite erneut vom Server.
  • Morph ist direkt im Kern.

Attribute, Events und Header nennen wir in dieser Serie nur bei konkreten Situationen. Alle sind in der Referenz von htmx 4 beschrieben.

Installation

bash
npm install htmx.org@4

Die Einstiegsdatei ist für Vite und Webpack Encore gleich:

javascript
import htmx from 'htmx.org'
// Extensions registrieren sich am globalen htmx, deshalb erst nach dem Kern.
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 nach der Auswertung des Moduls von selbst. Extensions und Listener registrieren Sie direkt im Modul.
  • Skripte gehören in den <head>. Beim Morph des <body>-Inhalts würde sich ein Skript am Ende der Seite wie ein gewöhnliches Element verhalten.

Grundeinstellung

Den meisten Websites genügt eine einzige Einstellung am <body>:

xml
<body hx-boost:inherited="true"
      hx-swap:inherited="innerMorph"
      hx-head:inherited="merge">

Die Controller ändern sich nicht und liefern ganze Seiten. Genauso kann auch eine Suche mit Filtern und Paginierung als gewöhnliche GET-Formulare und Links laufen, ohne Fragmente und ohne Auswertung von Headern auf dem Server.

hx-swap:inherited erben auch Elemente mit eigenem hx-get oder hx-post, sie sollten deshalb ein eigenes hx-swap haben.

Wenn in der Seite etwas außerhalb des Swaps bleiben muss, zum Beispiel von einem fremden Skript eingefügte Elemente, swappen Sie nur den Hauptcontainer:

xml
<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 nötig. Wenn das Target nicht <body> ist, ergänzt hx-head den Head nur, und Titel sowie Meta-Tags häufen sich an.
  • Das Target geben Sie per Selektor an, nicht mit target:this.
  • hx-target und hx-select gehören nicht an #app, sonst würden alle Kindelemente sie erben.
  • Ohne hx-history-elt würde Zurück den ganzen <body> austauschen.

Was boosten und was nicht

Der Boost betrifft Links und Formulare auf demselben Origin. Er lässt Links mit download, mit einem anderen target als _self und Links mit # am Anfang aus. Klicks mit Strg, Cmd oder Umschalt überlässt er dem Browser.

hx-boost="false" gehört an:

  • Links in die Administration oder einen anderen Bereich mit eigenen Assets,
  • Seiten mit völlig anderem Layout, zum Beispiel die Anmeldung ohne Header und Footer,
  • das Anmeldeformular und Links, die etwas ändern (Abmelden per GET),
  • einen Umschalter, der State nur im Browser verwaltet.

Ein Morph zwischen ungleichen Layouts ist ein unnötiges Risiko, ein vollständiges Laden ist zuverlässiger.

Morph und Identität der Elemente

Morph paart Elemente nach Tag und id. 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.

Bewährt hat sich, dem Hauptcontainer des Inhalts eine id nach der Route zu geben:

xml
<main id="content-{{ app.request.attributes.get('_route')|default('page') }}">
    {% block content %}{% endblock %}
</main>

Dieselbe Route wird gemorpht und der State bleibt, eine andere Route tauscht den Inhalt sauber aus. Navigation, Sidebar und Footer bleiben außerhalb des Containers, ihr State übersteht den Wechsel. Worauf Sie achten sollten:

  • Ein Container ohne id oder mit gemeinsamer id überträgt State zwischen nicht zusammengehörenden Elementen. Der Text aus dem Feld search erscheint im Feld email auf der nächsten Seite, ein Passwort wird lesbar in einem Textfeld angezeigt (Morph ändert type, den Wert lässt er) und ein Listener direkt am Button „Löschen“ wird nach dem Klick auf den neuen Button „Teilen“ ausgelöst.
  • Gleiche innere id unter verschiedenen Containern. Hat ein Element im Container auf beiden Seiten dieselbe id, überträgt Morph es samt State in den neuen Container, und die Komponente darin kann ihren State verlieren. Wählen Sie id innerhalb der Seite deshalb so, dass sie sich zwischen verschiedenen Seiten nicht wiederholen, sofern das Element den Wechsel nicht absichtlich überstehen soll.
  • Formulare sollten eine eigene id haben, beim Anmeldeformular ist das Pflicht.
  • Listener hängen Sie per Delegation an document, oder Sie entfernen sie vor dem Swap.
  • Morph löscht Attribute, die nicht in der Antwort stehen. Ein im Browser gesetztes Flag wie data-map-initialized verschwindet und das Widget wird ein zweites Mal initialisiert. Nehmen Sie es in htmx.config.morphIgnore auf (das Standard-Array erweitern, nicht überschreiben), oder ermitteln Sie den State über die Bibliothek, zum Beispiel Chart.getChart(canvas).
  • Morph behält die Werte von Formularfeldern, er eignet sich also nicht zum Zurücksetzen eines Formulars (siehe Teil 2).
  • Attributnamen müssen durch setAttribute() gehen. Morph kopiert damit Attribute, und WebKit lehnt einen Namen ab, den der HTML-Parser akzeptiert, zum Beispiel @click aus Alpine.js. Morph bricht dann mittendrin ab, ohne Fehler in der Konsole. Details stehen im dritten Teil.

Ein Element nehmen Sie mit dem Attribut hx-morph-skip vom Morph aus, hx-morph-skip-children aktualisiert nur seine Attribute.

Head, Titel und Sprache

Die Extension hx-head führt den <head> nach exakter Übereinstimmung der Tags zusammen. Titel, Description, canonical und JSON-LD werden nach dem Wechsel ausgetauscht.

  • Analytics und andere einmalige Skripte im Head markieren Sie mit hx-preserve="true", sonst würden sie bei jedem Wechsel gelöscht und erneut ausgeführt.
  • <html lang> ändert sich nicht, weil htmx nur den Inhalt von <body> swappt. Nach dem Sprachwechsel bliebe die alte Sprache für Screenreader und Übersetzer. Geben Sie dem Sprachumschalter hx-boost="false", oder setzen Sie lang aus der Antwort:
javascript
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 Swap, mit aktivierten View Transitions erst nach dem Ende des Übergangs. Ein zweiter schneller Klick kann dann im Tab den Titel der ersten Seite stehen lassen. ignoreTitle:true in hx-swap überlässt den Titel nur hx-head, das ihn sofort setzt.
  • CSS nicht aus per Lazy-Import geladenem JavaScript laden. Vite fügt es in den <head> ein, der nächste Wechsel entfernt es als unbekannt und es wird nicht wieder hinzugefügt. Lazy laden Sie nur JavaScript.

Ein Dropdown-Menü über :hover und :focus-within bleibt nach dem Klick auf einen Link darin offen, weil der Link den Fokus auch nach dem Wechsel behält. Beim vollständigen Laden der Seite geht der Fokus von selbst verloren, mit htmx muss man ihn entfernen:

javascript
document.addEventListener('htmx:before:request', (event) => {
    if (event.detail.ctx.request.headers['HX-Boosted']) document.activeElement?.blur()
})

Prefetch

Die Extension hx-preload lädt die Seite schon bei mousedown oder touchstart. Bevor der Nutzer die Taste loslässt, ist der Inhalt unterwegs oder fertig. Für geboostete Links genügt es, die Extension zu laden, das Attribut braucht es nur, um das Verhalten zu ändern:

xml
<body hx-preload:inherited="mousedown, touchstart">

Prefetch ist ein echter Request und nutzt keinen HTTP-Cache, Sie müssen dafür also kein Cache-Control setzen. Er betrifft nur GET.

Aktionen, die etwas ändern, vom Prefetch ausnehmen. Prefetch unterscheidet keine Maustasten und startet auch bei Rechtsklick, Mittelklick oder dem Beginn eines Scrollens auf dem Smartphone. Ein Link per GET würde so eine Aktion ausführen, die der Nutzer nicht wollte:

  • Abmelden,
  • Hinzufügen zum Warenkorb oder zu den Favoriten,
  • Löschen, Archivieren, Als gelesen markieren,
  • Abstimmen, Bestätigen oder Abbestellen,
  • Umschalten von Sprache oder Währung, das in der Session gespeichert wird,
  • ein Export, dessen Erzeugung auf dem Server teuer ist,
  • alles, was eine Flash-Nachricht abholt.

Am saubersten sind solche Aktionen als Formular per POST mit CSRF-Token, Prefetch betrifft POST nicht. Muss es ein Link bleiben, nehmen Sie ihn aus: hx-preload="none" schaltet nur den Prefetch ab und lässt den Boost, hx-boost="false" schaltet beides ab. Abmelden und Sprachwechsel gehören eher zu hx-boost="false", weil sie die ganze Seite ändern.

Weitere Folgen:

  • Aufrufzähler zählen leicht zu hoch, auch ein abgebrochenes Drücken wird mitgezählt. Prefetch auf dem Server nicht herausfiltern: Nach dem Klick ist er oft der einzige Request, es würden also fast alle echten Aufrufe verschwinden. Auf einer Liste, auf der die Scrollgeste oft auf einem Link beginnt, schränken Sie die Trigger mit hx-preload:inherited="mousedown" ein.
  • Die Standardgültigkeit von 5 Sekunden nicht verlängern, wo persönlicher State im Spiel ist. Einen Link ohne id behält Morph samt geladener Antwort, eine längere Gültigkeit würde nach einem Kauf also den alten Kontostand zeigen. Auf einer reinen Content-Website lässt sie sich verlängern, zum Beispiel mousedown timeout:30s.
  • Prefetch auf Hover lohnt sich nicht. Die Extension hat keine Verzögerung, ein Überfahren des Menüs rendert also alle Seiten dahinter. Eine eigene Verzögerung führte zu doppelten Requests und brachte gegenüber mousedown nur einige zehn Millisekunden.

View Transitions

htmx.config.transitions = true schaltet View Transitions ein. Mit der Standarddauer dauert ein Übergang etwa 300 ms, was auf einer Website mit Prefetch langsamer wirkt, als sie ist. Kürzen Sie alle drei Pseudo-Elemente, die Dauer des ganzen Übergangs bestimmt group:

css
::view-transition-group(root),
::view-transition-old(root),
::view-transition-new(root) {
    animation-duration: 100ms;
}
  • Bei prefers-reduced-motion schalten Sie Übergänge gar nicht ein. Schon der Snapshot friert die Seite für die Dauer des Übergangs ein, auch wenn die Animation im CSS abgeschaltet ist.
  • Ein Element mit eigenem view-transition-name wird separat animiert. Die Unterstreichung des aktiven Menüpunkts als eigenes <span> mit eigenem Namen gleitet so zwischen den Seiten, einschließlich der Breitenänderung. Der Name muss im Dokument eindeutig sein, zwei gleiche Namen brechen den Übergang ab.
  • Verwenden Sie kein globales scroll-behavior: smooth. Die Seite würde nach jedem Wechsel und nach Zurück langsam scrollen. Smooth Scroll schalten Sie nur bei konkreten Links ein.

Error Pages

htmx swappt auch eine Fehlerantwort, ein geboosteter Link auf eine nicht existierende Seite zeigt also Ihre 404-Seite.

  • Die Templates in templates/bundles/TwigBundle/Exception/ müssen das ganze Layout haben.
  • In den Umgebungen dev und test werden sie nicht gerendert, weil kernel.debug läuft. Testen Sie sie auf einem Build für prod.
  • Bei der Variante mit #app würde eine Antwort ohne #app den Inhalt löschen und eine leere Seite hinterlassen. Das passiert bei einem 502 vom Proxy während eines Deployments, bei einem fatalen PHP-Fehler oder bei der Fallback-Error-Page von Symfony. Eine Absicherung übergibt die Navigation an den Browser:
javascript
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 Session

Wenn die Session abläuft, leitet Symfony auf den Login um und fetch folgt dem Redirect von selbst. Für einen geboosteten Link mit Login im selben Layout funktioniert das ohne Anpassung. Hat der Login ein anderes Layout oder wird ein Fragment mit eigenem Target geladen, würde die Anmeldeseite an einer fremden Stelle eingefügt. Es hilft, wenn der AuthenticationEntryPoint bei htmx ein vollständiges Laden erzwingt:

php
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);
}

Symfony speichert vor diesem Aufruf die Adresse des Requests als Ziel nach der Anmeldung. Bei einem Fragment per GET käme der Nutzer nach der Anmeldung auf das nackte Fragment, löschen Sie das Ziel in diesem Zweig deshalb mit TargetPathTrait::removeTargetPath().

Zurück und Scrollposition

htmx speichert beim Wechsel keine Kopie der verlassenen Seite, Zurück lädt sie also erneut vom Server. Der Back/Forward Cache des Browsers hilft nicht, er funktioniert nur beim Wechsel zwischen Dokumenten.

  • In einem Browser mit Navigation API fängt htmx die Navigation ab, lädt die Seite und swappt sie. In Firefox und Browsern ohne diese API übernimmt popstate dieselbe Arbeit.
  • Der Request trägt nur HX-History-Restore-Request, HX-Request und HX-Boosted fehlen. Der Server liefert also die ganze Seite ohne Anpassung.
  • Der Swap läuft über outerSync, nicht über Morph. Der State von Komponenten und angefangene Eingaben gehen verloren.

Daraus folgt:

  • State, der Zurück überstehen soll, gehört in die URL. Filter, Seite und Suche müssen in der Adresse stehen. Ein Fragment mit hx-push-url braucht eine Adresse, unter der der Server die ganze Seite liefert.
  • Widgets müssen sich abbauen und neu erstellen lassen. Diagramme bauen Sie in htmx:before:swap ab und in htmx.onLoad neu auf.
  • Aufräumen von State außerhalb des Swaps gehört ebenfalls in htmx:before:swap, zum Beispiel das Entfernen einer Klasse, die bei geöffnetem Menü das Scrollen sperrt.
  • Ohne Request an den Server geht Zurück mit der Extension hx-history-cache, die Seiten in sessionStorage speichert, Zurück sendet dann keinen Request. Der Preis ist, dass die Daten nach der Rückkehr nicht aktuell sein müssen.

Scroll Restoration

Der Browser stellt die Scrollposition erst nach dem Ende der abgefangenen Navigation wieder her, die Seite wird also zuerst oben gerendert und springt dann nach unten. Firefox macht es umgekehrt: Er scrollt noch den alten Inhalt, und von einer längeren auf eine kürzere Seite wird die Position nach dem Swap abgeschnitten. Die Lösung ist, sich die Position selbst nach dem Schlüssel des History-Eintrags zu merken:

javascript
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 wird nur bis zum Reload wiederhergestellt */ }
}

export default function registerScrollRestore() {
    if (!window.navigation) return

    const positions = load()
    let pending = null

    // Dieselbe Bedingung, unter der htmx statt die Navigation abzufangen auf popstate hört.
    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 Listener von htmx 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 fügt ein modales Fenster hinzu: die Seite ändert sich nicht, kein Swap.
        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 View Transition kommt htmx:after:swap erst nach dem Übergang. Die Scrollposition
    // gehört deshalb ans Ende seines Tasks, damit schon der neue Snapshot richtig gescrollt ist.
    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, die mit der History verknüpft sind, stehen im dritten Teil.

Lazy Loading

Es lohnt sich bei Inhalten, die groß oder aufwendig in der Erstellung sind und die die meisten Besucher nicht öffnen, zum Beispiel ein Preisverlaufsdiagramm, die Bestellhistorie oder die Vorschau eines Dokuments in einem eingeklappten Abschnitt:

xml
<details>
    <summary>Preisverlauf</summary>
    <div hx-get="{{ path('app_product_price_history', {id: product.id}) }}"
         hx-trigger="toggle once from:'closest details'"
         hx-swap="innerHTML"></div>
</details>
php
#[Route('/produkt/{id}/preisverlauf', 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-trigger muss in Anführungszeichen stehen. Klammern kann htmx nicht, und ein Fehler beim Parsen stoppt die Verarbeitung der restlichen Seite.
  • Weitere nützliche Trigger: load (sofort nach dem Einfügen), revealed (wenn das Element in den Viewport kommt), intersect mit Schwellenwert.
  • Wenn ein Wert aus der Adresse ein Template oder eine Datei bestimmt, schränken Sie ihn über requirements ein.
  • Reservieren Sie Platz für den Inhalt, damit sich die Seite nach dem Laden nicht verschiebt.
  • Ein Fragment ohne personenbezogene Daten kann öffentlich gecacht werden.

JavaScript für neuen Inhalt

  • Widgets initialisieren Sie in htmx.onLoad(callback). Es läuft beim ersten Laden und nach jedem Swap, auch nach Zurück. Die Initialisierung muss wiederholbar sein.
  • htmx:after:swap wird an dem Element ausgelöst, das den Request gesendet hat, nicht am Target. Für die Initialisierung neuen Inhalts eignet es sich nicht.
  • Schwere Bibliotheken (Syntax-Highlighting, Karten, Diagramme) laden Sie per import(), erst wenn ihr Element auf der Seite ist. Im Haupt-Bundle bleibt nur die Bedingung:
javascript
function highlightCode(root = document) {
    if (!root.querySelector?.('pre code:not([data-highlighted])')) return
    import('./js/code-highlight.js').then(({ default: highlight }) => highlight(root))
}

highlightCode()
htmx.onLoad(highlightCode)

SEO

htmx ist für Suchmaschinen unsichtbar, solange eine einzige Regel gilt: Jede Adresse liefert ohne JavaScript die ganze Seite. Der Crawler sendet kein HX-Request, er bekommt also dasselbe wie der Nutzer beim ersten Laden.

bash
curl -s https://example.com/leistungen | grep -E '<title>|rel="canonical"|<h1'
curl -sI https://example.com/gibt-es-nicht | head -1
  • Links sind <a href>. Der Crawler folgt keinem hx-get an einem Button oder <div>.
  • Was gefunden werden soll, steht im ersten HTML. Der Crawler klickt nicht, scrollt nicht und öffnet keinen Abschnitt. Per Lazy Loading laden Sie nur Ergänzungen. Text, der gefunden werden soll, lassen Sie im HTML, auch wenn er in einem eingeklappten <details> steht. Viele AI-Crawler führen JavaScript gar nicht aus.
  • Titel, Description, canonical und hreflang stehen in der Antwort jeder Adresse, hx-head tauscht sie dann auch für den Nutzer aus.
  • Echte Statuscodes. 404 muss 404 sein, ein Umzug 301. Eine Error Page mit Status 200 käme in den Index.
  • Fragmente gehören nicht in den Index. Eigene Routen bekommen X-Robots-Tag: noindex und stehen nicht in der Sitemap. Sperren Sie sie nicht in robots.txt, sonst liest der Crawler den Header nicht.
  • Core Web Vitals hängen vor allem am ersten Laden, nicht an den Wechseln. Achten Sie auf die Größe des Bundles und die Antwortzeit des Servers, weil Prefetch und Zurück über ihn laufen.

Paginierung und Filter aus SEO-Sicht beschreibt Teil 2.

Messung

Ein Wechsel per htmx ist kein Laden der Seite, Analytics zählt die Aufrufe also nicht von selbst.

  • Einen eigenen Pageview senden Sie nach htmx:after:swap, nur wenn sich die Adresse geändert hat. Ein geöffnetes Fenster oder ein abgeschicktes Formular zählt dann nicht.
  • Der Titel ist in diesem Moment schon neu, hx-head hat ihn bereits bei der Antwort zusammengeführt. Events nach dem Eintrag in die History kommen noch vor dem Swap, wenn die Seite den alten Inhalt zeigt.
  • Google Tag Manager erfasst Wechsel über den Trigger History Change. Mit einem eigenen Senden haben Sie zusätzlich zwei Aufrufe.
  • Zurück zählt als Aufruf.
  • Das Analytics-Skript markieren Sie mit hx-preserve="true".

Tests und Deployment

  • Vor einem Update von htmx testen Sie einen Wechsel und das Absenden eines Formulars auch in einem älteren Browser und gehen die Vorkommen von htmx: in den Templates durch.
  • In End-to-End-Tests warten Sie auf htmx:finally:request und hängen den Listener im selben Skript vor dem Klick an. Das Warten auf das Verschwinden der Klasse .htmx-request direkt nach dem Klick kann enden, bevor die Klasse überhaupt erscheint.
  • Zurück testen Sie in Chrome und Firefox, es läuft dort über anderen Code. Testen Sie mit View Transitions und mit prefers-reduced-motion.
  • Wechsel testen Sie auch in WebKit. Ein Fehler beim Morph muss dort nichts ausgeben, Sie erkennen ihn daran, dass nach dem Klick ein Teil der alten Seite stehen bleibt.
  • Error Pages testen Sie auf prod und einmal auch einen Ausfall ohne Ihr Template, zum Beispiel einen 502 vom Proxy.

Checkliste

  • hx-head und hx-preload nach dem Kern importiert, Skripte im <head>, einmalige mit hx-preserve="true"
  • <body> mit hx-boost:inherited und Morph, oder #app mit hx-head="merge" und hx-history-elt
  • Hauptcontainer mit id nach der Route, innere id wiederholen sich nicht zwischen Seiten
  • hx-boost="false" an Abmelden, Anmelden, Administration und anderen Layouts
  • Aktionen, die etwas ändern (Warenkorb, Löschen, Abstimmen…), per POST, sonst hx-preload="none"
  • <html lang> entspricht nach dem Sprachwechsel der Seite
  • AuthenticationEntryPoint liefert bei htmx HX-Redirect, wenn der Login ein anderes Layout hat oder Fragmente hinter der Anmeldung liegen
  • Error Pages mit ganzem Layout, bei #app Absicherung gegen eine leere Auswahl
  • Gekürzte View Transitions, abgeschaltet bei prefers-reduced-motion, kein globales scroll-behavior: smooth
  • Modul für Scroll Restoration vor dem Start von htmx registriert
  • State, der Zurück überstehen soll, steht in der URL
  • Widgets in htmx.onLoad, wiederholbare Initialisierung, Client-Flags in morphIgnore
  • SEO: curl liefert die ganze Seite, Links sind <a href>, Hauptinhalt im ersten HTML, Fragmente mit noindex
  • Pageview nach htmx:after:swap, nicht nach der Änderung der History
  • Zurück in Chrome und Firefox getestet, Wechsel auch in WebKit, Error Pages auf prod

Schlagwörter Backend, PHP, JavaScript

Teilen LinkedIn Facebook

Brauchen Sie mehr als einen Artikel?

Ein paar Sätze zu Ihrem Anliegen genügen.