Přeskočit na obsah
iD-SIGN
Ptejte se

Symfony s htmx snadno a dobře

Kompletní postup pro Symfony a htmx 4: navigace a prefetch, krok zpět, formuláře, načítání částí obsahu a SEO. Včetně chyb, které se projeví až v provozu.

21 min čtenívydáno 11/2026

V tomto článku 12 kapitol
  1. Principy htmx
  2. Instalace
  3. Navigace a prefetch
  4. Krok zpět a vpřed
  5. Formuláře a validace
  6. Načítání částí obsahu
  7. SEO
  8. Měření
  9. Testy a nasazení
  10. Kontrolní seznam pro nový projekt
  11. Kdy po htmx nesáhnout
  12. Přehled atributů, hlaviček a událostí

Tento návod shrnuje, co jsme se naučili při stavbě několika projektů na Symfony a htmx, od obsahových webů po aplikaci s účty, nákupy a platbami. Chyby v něm nejsou teoretické: většinu z nich jsme opravdu udělali nebo je zachytila kontrola kódu.

Návod platí pro htmx 4 a Symfony 8. Spojení s klientskými knihovnami, jako je Alpine.js, popíšeme v samostatném článku.

Principy htmx

Server vrací hotové HTML a htmx ho vkládá do stránky. Není potřeba API pro frontend ani druhá vrstva stavu a validace. Šablony, formuláře a zabezpečení zůstávají v Symfony.

Každá interakce projde stejným cyklem: spouštěč → požadavek → odpověď → výběr části odpovědi → výměna v DOM → dokončení → zápis do historie. Atributy určují, co se v jednotlivých krocích stane. Jejich přehled spolu se způsoby výměny, hlavičkami a událostmi je na konci článku.

Pravidla, která je dobré znát předem

  • Dědičnost je explicitní. Potomkům se předá jen atribut se sufixem :inherited. Bez něj se odkazy neboostují a žádná chyba se neobjeví.
  • Rozšíření se aktivují importem. Žádný atribut na ně není potřeba.
  • Události mají tvar htmx:faze:akce, například htmx:before:swap. Posluchač na překlepnutém názvu se tiše nespustí.
  • Vkládá se všechno kromě 204 a 304, včetně chybových stránek.
  • Historie neukládá kopie stránek. Krok zpět stránku znovu stáhne ze serveru.
  • Morph je přímo v jádře. Nepotřebuje žádné rozšíření.

Instalace

bash
npm install htmx.org@4

Vstupní soubor funguje stejně s Vite přes Symfony Reprise i s Webpack Encore:

javascript
import htmx from 'htmx.org'
// Rozšíření se registrují na globální htmx, proto až za jádrem.
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() volat nemusíte, htmx se spustí samo až po vyhodnocení modulu. Rozšíření i posluchače proto registrujte hned v modulu, ne později.
  • Skripty dávejte do <head>. Morph obsahu <body> by skript na konci stránky bral jako obyčejný prvek.

Základní nastavení

Pro většinu projektů stačí jedno nastavení na <body>:

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

Controllery se nemění a dál vracejí celé stránky. Tímto způsobem může běžet i hledání s filtry a stránkováním jako obyčejné GET formuláře a odkazy, bez jediného fragmentu a bez čtení hlaviček na serveru.

hx-swap:inherited se předá i prvkům s vlastním hx-get nebo hx-post. Ty proto mají vždy mít vlastní hx-swap.

Druhou variantu použijte, když ve stránce musí zůstat něco mimo výměnu, například prvky vložené cizím skriptem. Morph celého <body> by je odstranil, protože v odpovědi nejsou.

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" je tu nutné. Když cíl není <body>, hx-head hlavičku jen doplňuje a titulky i meta značky se hromadí.
  • Cíl uveďte selektorem, ne target:this.
  • hx-target a hx-select nedávejte na #app, zdědili by je všichni potomci.
  • Bez hx-history-elt by krok zpět vyměnil celé <body>.

Co boost převezme a co vyjmout

Boost se týká odkazů a formulářů na stejném originu. Vynechá odkazy s download, s jiným target než _self a odkazy začínající #. Klik s Ctrl, Cmd nebo Shiftem nechává na prohlížeči.

hx-boost="false" dejte na:

  • odkazy na administraci nebo jinou část s vlastními assety,
  • stránky s úplně jiným rozložením, například přihlášení bez hlavičky a patičky,
  • přihlašovací formulář a odkazy s vedlejším účinkem,
  • přepínač filtru, který řeší stav v prohlížeči.

Morph mezi nesourodými rozloženími je zbytečné riziko. Plné načtení je jednodušší a spolehlivější.

Morph a identita prvků

Morph páruje prvky podle značky a id. Prvek bez id se sloučí s jakýmkoli prvkem se stejnou značkou na stejném místě. Prvek s id se sloučí jen s prvkem se stejným id, jinak se vloží jako nový a starý zmizí. Proto záleží na tom, jaké id má hlavní kontejner stránky.

  • Stejné id dejte jen stránkám stejného typu, například dvěma detailům článku. Tam sloučení dává smysl a stav zůstane. Stránky s jinou strukturou mají mít id jiné. Jednoduchá náhrada je id podle routy, dvě routy se stejnou strukturou tím jen přijdou o slučování.
  • id patří na hlavní kontejner, ne na <body>. innerMorph slučuje jen potomky, takže id ani třídu z <body> nečte a nemění. Kontejner bez id se chová stejně jako kontejner se společným id.
  • Sloučení přenáší stav mezi nesouvisejícími prvky. Při společném id kontejneru se text z pole search objeví v poli email na další stránce a heslo se objeví čitelně v textovém poli, protože morph změní type, ale hodnotu nechá. Obsluha tlačítka „Smazat“ pověšená přímo na prvek se spustí po kliknutí na nové tlačítko „Sdílet“.
  • Formuláře mají mít vlastní id. S různými id formulářů se hodnota nepřenese ani pod společným kontejnerem. U přihlašovacího formuláře je to povinnost.
  • Posluchače věšte přes delegaci na document, nebo je před výměnou odstraňte.
  • Prvek, který má přechod přežít, má na obou stránkách stejné id. Přenese se i s hodnotou, a to i pod kontejnery s různými id.
  • Morph maže atributy, které v odpovědi nejsou. Příznak jako data-map-initialized nastavený v prohlížeči zmizí a widget se inicializuje podruhé. Přidejte ho do htmx.config.morphIgnore (rozšiřte výchozí pole, nepřepisujte ho), nebo stav zjišťujte přes knihovnu, například Chart.getChart(canvas).
  • Morph zachovává hodnoty polí. Na reset formuláře se nehodí, viz oddíl o formulářích.
  • Názvy atributů musí projít přes setAttribute(). Morph jím kopíruje atributy a prohlížeče se starším ověřováním názvů, například ve WebKitu, odmítnou název jako @click, zkratku z Alpine.js. Sloučení skončí v půlce, na stránce zůstane směs nového a starého obsahu a htmx výjimku spolkne, takže v konzoli nic nevidíte. Parser HTML takový atribut přijme, takže v Chromu vše funguje. Použijte plný zápis x-on:click.

Prvek vyjmete atributem hx-morph-skip. hx-morph-skip-children aktualizuje jen jeho atributy.

Hlavička stránky a jazyk

Rozšíření hx-head sloučí <head> podle přesné shody značek. Titulek, popis, canonical i JSON-LD se tak po přechodu vymění.

  • Analytické a další jednorázové skripty v hlavičce označte hx-preserve="true", jinak by se při každém přechodu mazaly a spouštěly znovu.
  • <html lang> se nemění, protože htmx vyměňuje jen obsah <body>. Po přepnutí jazyka zůstane starý jazyk pro čtečky i překladače. Přepínači dejte hx-boost="false", nebo lang nastavte z odpovědi:
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 zapisuje titulek až po výměně. Když máte zapnuté View Transitions, přijde zápis až po doběhnutí přechodu a druhý rychlý klik pak může v záložce nechat titulek první stránky. ignoreTitle:true v hx-swap nechá titulek jen na hx-head, který ho nastavuje hned.
  • Styly nenačítejte z líně importovaného JavaScriptu. Vite je vloží do <head>, další přechod je jako neznámé odstraní a znovu už se nepřidají. Líně načítejte jen JavaScript.

Prefetch

Rozšíření hx-preload stáhne stránku už při mousedown nebo touchstart. Po puštění tlačítka je obsah na cestě nebo hotový. Pro boostované odkazy stačí rozšíření načíst, atribut je potřeba jen pro změnu chování:

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

Prefetch je skutečný požadavek a nepoužívá HTTP cache, takže kvůli němu nemusíte nastavovat Cache-Control. Týká se jen GET. Z toho plyne:

  • GET s vedlejším účinkem musí mít hx-boost="false". Prefetch nerozlišuje tlačítka myši. Odkaz na odhlášení přes GET by člověka odhlásil i pravým klikem nebo začátkem posunu na mobilu. Platí to pro všechno, co GET mění, například vyzvednutí flash zprávy.
  • Počítadla zobrazení mírně nadhodnocují, protože se započítá i rozmyšlené stisknutí. Prefetch na serveru nefiltrujte: po kliknutí bývá jediným požadavkem, takže by zmizela skoro všechna skutečná zobrazení. Na seznamu, kde gesto posunu často začíná na odkazu, zúžete spouštěče přes hx-preload:inherited="mousedown".
  • Neprodlužujte platnost tam, kde je osobní stav. Výchozích 5 sekund je rozumných. Odkaz bez id si morph ponechá i se staženou odpovědí, takže delší platnost by po nákupu ukázala starý zůstatek. Na čistě obsahovém webu lze prodloužit, například mousedown timeout:30s.
  • Spouštění při najetí myší jsme zavrhli. Rozšíření nemá prodlevu, takže přejetí přes menu vyrenderuje všechny stránky za ním. Vlastní prodleva vedla k dvojitým požadavkům a oproti mousedown přinesla jen desítky milisekund.

Přechody mezi stránkami

htmx.config.transitions = true zapne View Transitions. Stojí to čas: s výchozí délkou jsme naměřili asi 300 ms na přechod, což na webu s prefetchem působí pomaleji, než web je. Zkraťte všechny tři části, délku celého přechodu určuje skupina:

css
::view-transition-group(root),
::view-transition-old(root),
::view-transition-new(root) {
    animation-duration: 100ms;
}
  • Při omezeném pohybu přechody vůbec nezapínejte. Samotný snímek stránku na dobu přechodu zmrazí, i kdyby byla animace v CSS vypnutá.
  • Stejné view-transition-name na dvou prvcích přechod zruší.
  • Nepoužívejte globální scroll-behavior: smooth. Stránka by se po každém přechodu a kroku zpět plynule odvíjela. Plynulý posun zapínejte jen u konkrétních odkazů.

Chybové stránky

htmx vloží i chybovou odpověď, takže boostovaný odkaz na neexistující stránku ukáže vaši stránku 404.

  • Šablony v templates/bundles/TwigBundle/Exception/ musí mít celé rozložení.
  • V prostředí dev a test se nevykreslí, protože tam běží kernel.debug. Zkoušejte je na sestavení pro prod.
  • U varianty s #app by odpověď bez #app (502 z brány při nasazení, fatální chyba PHP) smazala obsah a zůstala by prázdná stránka. Pomůže pojistka, která předá navigaci prohlížeči:
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)
    }
})

Expirované přihlášení

Když přihlášení expiruje, Symfony přesměruje na login a fetch přesměrování sám následuje. Co se stane dál, závisí na požadavku a na rozložení přihlašovací stránky:

  • Boostovaný odkaz, login ve stejném rozložení jako zbytek webu: funguje bez úprav. Přihlašovací stránka se sloučí jako každá jiná a do historie se zapíše její adresa.
  • Boostovaný odkaz, login s jiným rozložením: morph mezi nesourodými stránkami je zbytečné riziko, u varianty s #app může login navíc tento prvek vůbec nemít.
  • Fragment s vlastním cílem: přihlašovací stránka se vloží do cíle, ať má jakékoli rozložení.

V posledních dvou případech pomůže, když AuthenticationEntryPoint u htmx vynutí plné načtení:

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 si před tímto voláním uloží adresu požadavku jako cíl po přihlášení a požadavek htmx nepovažuje za XHR. Z kódu frameworku plyne, že po fragmentu přes GET by se člověk po přihlášení dostal na holý fragment. Pokud máte fragmenty za přihlášením, odstraňte v této větvi uložený cíl přes TargetPathTrait::removeTargetPath().

Krok zpět a vpřed

Tady se htmx 4 od běžné představy liší nejvíc. Při přechodu si neukládá kopii opuštěné stránky, takže ji krok zpět musí stáhnout ze serveru znovu. Prohlížeč to za něj neudělá: jeho back/forward cache funguje jen při přechodu mezi dokumenty a boostovaný přechod dokument nemění.

Co se děje

  • V prohlížeči s Navigation API htmx navigaci přeruší, stránku stáhne a vymění. Ve Firefoxu a prohlížečích bez tohoto API stejnou práci spouští popstate.
  • Požadavek nese jen HX-History-Restore-Request. Hlavičky HX-Request ani HX-Boosted chybí, takže server vrátí celou stránku bez jakékoli úpravy.
  • Výměna jde přes outerSync, ne přes morph. Potomci se nahradí, takže stav komponent i rozepsaná pole uvnitř se ztratí.
  • hx-head při obnově sloučí hlavičku z odpovědi, titulek tedy sedí.

Co z toho plyne

  • Stav, který má přežít krok zpět, patří do adresy. Filtr, stránka nebo hledání musí být v URL. Fragment s hx-push-url musí mít adresu, na které server vrátí celou stránku.
  • Data jsou po návratu čerstvá, ale obsluha kroku zpět je běžný požadavek, takže rychlost závisí na serveru. Rozšíření hx-history-cache tohle chování mění, s ním ale zkušenost nemáme.
  • Widgety se musí umět zrušit a znovu vytvořit. Grafy rušte v htmx:before:swap a vytvářejte v htmx.onLoad.
  • Úklid stavu mimo výměnu děláte taky v htmx:before:swap, například odstranění třídy zamykající posun při otevřeném menu.

Posun stránky

Prohlížeč obnoví posun až po skončení přerušené navigace. Stránka se proto nejdřív vykreslí nahoře a potom skočí dolů. Firefox to má naopak: posune ještě starý obsah a po výměně se z delší stránky na kratší posun ořízne. Řešení je pamatovat si posun sám podle klíče položky historie:

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) { /* privátní okno: posun se obnoví jen do reloadu */ }
}

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

    const positions = load()
    let pending = null

    // Stejná podmínka, za které htmx místo přerušení navigace poslouchá popstate.
    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)

        // Odchod adresním řádkem událost navigate nespustí.
        window.addEventListener('pagehide', () => {
            positions[navigation.currentEntry.key] = window.scrollY
            store(positions)
        })
    }

    // Musí se registrovat dřív než posluchač htmx, jinak scroll: 'manual' nepřijde včas.
    navigation.addEventListener('navigate', (event) => {
        positions[navigation.currentEntry.key] = window.scrollY
        store(positions)

        const isHistoryRestore = event.navigationType === 'traverse' && event.canIntercept && !event.hashChange

        // Položky se stejnou adresou přidává modální okno: stránka se nemění a výměna nepřijde.
        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
    }

    // S View Transition přijde htmx:after:swap až po přechodu. Posun proto patří
    // na konec jeho úlohy, aby už nový snímek byl posunutý správně.
    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)
}

Prohlížeče bez Navigation API modul vynechává. Posun tam obnovuje prohlížeč sám, i s popsaným skokem.

Modální okna

Člověk čeká, že Zpět zavře otevřené okno. htmx by ale místo toho znovu stáhlo stránku. Okno proto přidá do historie vlastní položku se stejnou adresou a stav všech oken drží jedno místo, které je s historií provázané:

javascript
let entryPath = null

const modal = {
    current: null,

    open(name) {
        history.pushState({ modal: name }, '')
        entryPath = location.pathname + location.search
        this.current = name
    },
}

// Krok zpět z položky okna jen zavře okno, stránka se znovu nestahuje.
document.addEventListener('htmx:before:history:restore', (event) => {
    const isLeavingEntry = entryPath === event.detail.path
    entryPath = null
    modal.current = null
    if (isLeavingEntry) event.preventDefault()
})

// Vpřed okno znovu otevře. htmx takovou položku nechá být, protože v ní nenajde svůj stav.
window.addEventListener('popstate', (event) => {
    if (!event.state?.modal) return
    entryPath = location.pathname + location.search
    modal.current = event.state.modal
})

// Odkaz z okna: nová stránka položku okna nahradí, jinak by Zpět vrátil jen adresu.
document.addEventListener('htmx:before:history:update', (event) => {
    if (entryPath !== null) event.detail.history.type = 'replace'
    entryPath = null
    modal.current = null
})
  • Zavření křížkem nebo Escapem volá history.back(), aby položka v historii nezůstala.
  • Po kroku v historii vrátí Navigation API fokus na <body>. Fokus na původní tlačítko proto vraťte až v navigatesuccess, ve Firefoxu po popstate.
  • Na okno použijte nativní <dialog> a showModal(). Modální vrstvu, Escape a zachycení fokusu dostanete od prohlížeče.

Formuláře a validace

Controller

Boostovaný odkaz posílá HX-Request také. Kdo se ptá jen na tuto hlavičku, pošle odkazu fragment místo stránky. Pomocná metoda proto kontroluje obě:

php
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()) {
        // Uložit a odeslat.

        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() nastaví stav 422 jen tehdy, když mu předáte FormInterface. S $form->createView() zůstane 200 a stav musíte nastavit sami.

Šablona

xml
{{ 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',
    },
}) }}
  • action a method nechte, bez JavaScriptu se formulář odešle běžně.
  • hx-swap="outerHTML" a ne morph. Morph zachovává hodnoty polí, takže by formulář po odeslání zůstal vyplněný.
  • hx-sync="this:drop" zahodí druhé odeslání.
  • novalidate, pokud chyby hlídá server. Firefox pro Android hlášky prohlížeče nezobrazuje, takže by prázdný formulář po kliknutí tiše stál. Atribut required u polí nechte kvůli čtečkám.

Chybové odpovědi

Validační 422 se vloží bez konfigurace. U fragmentu ale nechcete do formuláře vložit celou chybovou stránku. hx-status hledá nejdřív přesný kód, až potom zástupný znak, takže 422 musí být uvedená zvlášť. Jinak chyby validace tiše zmizí:

xml
<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' nestačí. hx-head sloučí hlavičku odpovědi ještě před vyhodnocením hx-status a htmx nastaví titulek, i když nic nevymění. Chybová stránka by tak přepsala titulek a hlavičku aktuální stránky. Hlavičku a titulek proto povolte jen při přechodu na celou stránku. U varianty s #app porovnejte cíl s tímto prvkem:

javascript
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
})

Selhání nesmí zahodit text. U formuláře, kde člověk něco dlouho psal, odpověď 5xx zahoďte a pod tlačítko vložte telefon a e-mail. Totéž udělejte při výpadku sítě:

javascript
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 })
})

Přesměrování

  • Cíl mimo aktuální stránku (platební brána, jiné rozložení): vraťte 200 s HX-Redirect. Na odpovědi 3xx htmx hlavičky nezpracovává a fetch přesměrování sám následuje.
  • hx-post s obyčejným přesměrováním vloží celou cílovou stránku do hx-target. Celá stránka vložená do malého rámečku je typický příznak.
  • Boostovaný formulář přesměrování zvládne a do historie zapíše konečnou adresu. Pokud ale cíl má jiné rozložení, zvolte hx-boost="false". To platí hlavně pro přihlášení, kde authenticator přesměrovává po úspěchu.

Modální formuláře

Formulář v okně vykreslete rovnou do stránky, nenačítejte ho líně. Vyhnete se potížím s časováním a se zpracováním skrytých prvků. Cena je možnost, že token CSRF na dlouho otevřené stránce expiruje, proto pro tento případ připravte srozumitelnou hlášku. V Symfony ji nastavíte přes csrf_message a přeložíte v doméně validators.

Přístupnost

  • Po výměně je fokus často na <body>. Po 422 ho dejte na první chybné pole, po odeslání na celé potvrzení. Oboje v htmx:after:settle.
  • Při odesílání použijte na tlačítku aria-disabled, ne disabled, který by fokus shodil na začátek stránky.
  • Stav odesílání ohlaste v předem vloženém prvku s role="status".

Načítání částí obsahu

Vybírejte v tomto pořadí a nezačínejte tím posledním:

  1. Celá stránka přes boost. Žádný kód na serveru. Stačí pro většinu výpisů, stránkování i filtrů.
  2. Vlastní routa pro fragment. Jedna adresa, jedna reprezentace. Vhodné pro líné načtení.
  3. Tatáž adresa, dvě reprezentace. Jen když je celá stránka opravdu moc drahá.

Líné načtení

Vyplatí se u obsahu, který je velký nebo náročný na přípravu a většina lidí ho neotevře. Typicky graf vývoje ceny, historie objednávek nebo náhled dokumentu ve sbaleném oddílu. Stránka se tak nezdržuje něčím, co většina návštěvníků nepotřebuje.

xml
<details>
    <summary>Vývoj ceny</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}/vyvoj-ceny', 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;
}
  • Víceslovný selektor v hx-trigger musí být v uvozovkách. Závorky htmx 4 neumí a chyba při zpracování zastaví zpracování zbytku stránky.
  • Když hodnota z adresy určuje šablonu nebo soubor, omezte ji přes requirements, ať se přes ni nedá načíst nic jiného.
  • Rezervujte místo pro obsah, ať se stránka po načtení neposune.
  • Fragment bez osobních údajů můžete cachovat jako veřejný.

Stránkování fragmentem

xml
<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">Další</a>

Odkaz potřebuje href i hx-get. Bez href ho robot nenajde a nefunguje ani bez JavaScriptu. Controller rozhoduje přes isFragmentRequest():

php
$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 chrání před situací, kdy cache vrátí fragment místo celé stránky. Kdo stejný výsledek získá z celé stránky přes hx-select, řeší jednu adresu s jedinou reprezentací a server nemusí nic rozlišovat. Platí za to přenosem větší odpovědi.

Aktualizace mimo cíl

hx-swap-oob aktualizuje další místa jednou odpovědí, například počet v košíku nebo hlášku k slevovému kódu.

  • Když po vyjmutí OOB prvků v odpovědi nic nezbude, htmx hlavní výměnu přeskočí. Prázdná odpověď proto nesmaže starou chybu. Pomůže hx-swap="innerHTML swapEmpty:true".
  • Řádky tabulky a položky seznamu zabalte do <template>, jinak je parser HTML zahodí.

JavaScript v načteném obsahu

  • Widgety inicializujte v htmx.onLoad(callback). Běží při prvním načtení i po každé výměně, včetně kroku zpět.
  • htmx:after:swap se spouští na prvku, který požadavek poslal, ne na cíli. Na inicializaci nového obsahu se nehodí.
  • Inicializace musí být opakovatelná.
  • Těžké knihovny načítejte přes import(), až když je na stránce jejich prvek.

SEO

htmx je pro vyhledávače neviditelné, pokud platí jediné pravidlo: každá adresa vrátí bez JavaScriptu celou stránku. Robot neposílá HX-Request, takže dostane to, co po prvním načtení člověk.

bash
curl -s https://example.com/sluzby | grep -E '<title>|rel="canonical"|<h1'
curl -sI https://example.com/neexistuje | head -1

Pravidla

  • Odkazy jsou <a href>. Robot neprochází hx-get na tlačítku ani na <div>.
  • Co se má najít, je v prvním HTML. Robot neklikne, neposouvá a neotevře oddíl. Přes htmx načítejte jen doplňky, jako je graf nebo historie. Text, který má být k nalezení, nechte v HTML, i když je ve sbaleném <details>. Řada robotů AI JavaScript vůbec nespouští.
  • Titulek, popis, canonical a hreflang jsou v odpovědi každé adresy. hx-head je pak vymění i uživateli.
  • Skutečné stavy. 404 musí být 404 a přesun 301. Chybová stránka se stavem 200 by se dostala do indexu.
  • Každá strana výpisu má vlastní canonical. Druhá strana má adresu s ?page= a titulek s číslem. Google výslovně nedoporučuje používat jako kanonickou první stranu. Do sitemapy další strany dávat nemusíte, vedou k nim odkazy.
  • hreflang na dalších stranách výpisu závisí na obsahu. Když mají jazykové verze stejné položky ve stejném pořadí a liší se jen překladem, odkažte na stejnou stranu v každém jazyce. Když se počet položek liší, hreflang na dalších stranách vynechte, protože odkaz na neexistující stranu je horší než žádný.
  • Filtrované a řazené varianty výpisu, které nemají být vstupní stránkou, vyřaďte z indexu přes noindex, jak doporučuje Google. Kanonická adresa na výpis bez parametru je pro vyhledávač jen doporučení a u stránky s jiným obsahem ji nemusí přijmout.
  • Stránkování má skutečné odkazy. Nekonečné načítání bez adres je pro roboty slepá ulička.
  • Fragmenty nepatří do indexu. Vlastní routy dostanou X-Robots-Tag: noindex a nejsou v sitemapě. Neblokujte je v robots.txt, robot by hlavičku nepřečetl.
  • Formulářové POST cesty a výsledky hledání v sitemapě nemají co dělat.

Rychlost

Prefetch a morph zrychlují přechody, ale Core Web Vitals v Search Console stojí hlavně na prvním načtení. htmx je malé, přesto bundle hlídejte a těžké knihovny načítejte líně. Sledujte i rychlost odpovědi serveru, protože prefetch i krok zpět jdou přes něj.

Měření

htmx přechod není načtení stránky, takže analytika zobrazení sama nepočítá.

  • Vlastní měření odešlete po htmx:after:swap, jen když se změnila adresa. Otevřené okno ani odeslaný formulář se pak jako zobrazení nepočítají.
  • Titulek je v tu chvíli nový, protože hx-head ho sloučil už při odpovědi. Událost po zápisu do historie přijde ještě před výměnou obsahu, takže stránka v tu chvíli ukazuje starý obsah.
  • Google Tag Manager zvládne přechody přes spouštěč History Change. Když k němu přidáte vlastní odeslání, máte dvě zobrazení.
  • Krok zpět se počítá jako zobrazení. Ověřte si, že jste to tak chtěli.
  • Skript měření označte hx-preserve="true".

Testy a nasazení

  • Před každou aktualizací htmx vyzkoušejte přechod a odeslání formuláře i ve starším prohlížeči a krok zpět z modálního okna na čerstvě načtené stránce. Projděte i výskyty htmx: v šablonách.
  • V end-to-end testech čekejte na htmx:finally:request a posluchač navěste ve stejném skriptu před klikem. Čekání na zmizení třídy .htmx-request hned po kliknutí může skončit dřív, než se třída objeví. Po výměně si znovu načtěte DOM v testovacím klientovi.
  • Krok zpět testujte v Chromu i ve Firefoxu, jde tam jiným kódem. Zkoušejte s přechody i s omezeným pohybem.
  • Přechody zkoušejte i ve WebKitu, nejen v Chromu. Chyba morphu tam nemusí nic vypsat, poznáte ji podle toho, že po kliknutí zůstane část staré stránky. Na Linuxu k tomu poslouží GNOME Web nebo MiniBrowser z WebKitGTK.
  • Chybové stránky testujte na prod a jednou si vyzkoušejte i výpadek bez vaší šablony, například 502 z proxy.
  • Pozor na otevřenou stránku při nasazení. hx-head porovnává značky doslova. Z kódu htmx plyne, že skript s novým hashem se při dalším přechodu přidá a spustí vedle starého. V provozu jsme to zatím neměřili, ale po nasazení s větší změnou to stojí za kontrolu.

Kontrolní seznam pro nový projekt

  • Rozšíření hx-head a hx-preload importovaná za jádrem
  • <body> s hx-boost:inherited a morphem, nebo #app s hx-head="merge" a hx-history-elt
  • Všechny skripty v <head>, jednorázové s hx-preserve="true"
  • hx-boost="false" na odhlášení, přihlášení, administraci, jiná rozložení a GET s vedlejším účinkem
  • <html lang> po přepnutí jazyka odpovídá stránce
  • Fragment jen při HX-Request && !HX-Boosted, u téže adresy s Vary
  • AuthenticationEntryPoint vrací u htmx HX-Redirect, pokud má login jiné rozložení nebo jsou za přihlášením fragmenty
  • Chybové šablony s celým rozložením, u #app pojistka na prázdný výběr
  • Přechody zkrácené a vypnuté při omezeném pohybu, žádný globální scroll-behavior: smooth
  • Modul na obnovu posunu registrovaný před spuštěním htmx
  • Stav modálních oken na jednom místě provázaném s historií
  • Stav, který má přežít Zpět, je v adrese
  • Formuláře: FormInterface do render(), outerHTML, hx-sync, hx-status:422 při zástupném 4xx, ochrana textu při 5xx, fokus po výměně
  • Widgety v htmx.onLoad, opakovatelná inicializace, klientské příznaky v morphIgnore
  • Atributy bez @ v názvu, přechody vyzkoušené i ve WebKitu
  • SEO: curl vrací celou stránku, odkazy jsou <a href>, hlavní obsah v prvním HTML, fragmenty s noindex
  • Měření po htmx:after:swap, ne po změně historie
  • Testy kroku zpět v Chromu i ve Firefoxu, chybové stránky na prod

Kdy po htmx nesáhnout

htmx se hodí tam, kde většinu práce dělá server: obsahové weby, formuláře, účty, administrace. Pro aplikaci, která drží hodně stavu v prohlížeči, například editor nebo práci offline, je lepší klientský framework.

Přehled atributů, hlaviček a událostí

Atributy, které budete používat

  • hx-get, hx-post: pošlou požadavek na danou adresu.
  • hx-trigger: určí událost, která požadavek spustí. Výchozí je klik, u formuláře odeslání.
  • hx-target: určí prvek, kterého se výměna týká. Výchozí je prvek sám.
  • hx-swap: určí způsob výměny. Výchozí je innerHTML.
  • hx-select: vybere z odpovědi jen část podle selektoru.
  • hx-boost: převede obyčejný odkaz nebo formulář na požadavek htmx.
  • hx-push-url: zapíše adresu do historie prohlížeče.
  • hx-sync: řeší souběh požadavků, například zahození druhého odeslání.
  • hx-status:CODE: změní chování podle stavu odpovědi, například hx-status:422.
  • hx-swap-oob: v odpovědi označí prvek, který se má vyměnit mimo hlavní cíl.
  • hx-preserve: nechá prvek beze změny, když je v původní i nové stránce.
  • hx-history-elt: určí prvek, který se vymění při kroku zpět. Výchozí je <body>.

Způsoby výměny

  • innerHTML, outerHTML: nahradí obsah nebo celý prvek. Hodí se, když se má stav zahodit.
  • innerMorph, outerMorph: sloučí nové HTML do stávajícího DOM a zachová fokus, posun, animace i hodnoty polí.
  • outerSync: sloučí atributy prvku a vymění jeho potomky. Používá ho htmx při kroku zpět.
  • none: nic nevloží.

Hlavičky

  • HX-Request: posílá ho každý běžný požadavek htmx, tedy i boostovaný odkaz.
  • HX-Boosted: posílá ho jen boostovaný prvek.
  • HX-History-Restore-Request: posílá ho obnova stránky při kroku zpět.
  • HX-Redirect (v odpovědi): vynutí plné načtení stránky. Funguje jen s odpovědí 2xx.
  • HX-Retarget, HX-Reswap, HX-Push-Url (v odpovědi): server jimi přepíše cíl, výměnu nebo adresu.

Události

Události probublávají, takže posluchače stačí pověsit na document. Většina nese v event.detail.ctx kontext požadavku (ctx.target, ctx.request, ctx.response, ctx.text). Tam, kde je to uvedené, ruší další postup event.preventDefault(). Seznamy jsou v pořadí, v jakém události přicházejí.

Požadavek:

  • htmx:config:request: požadavek se teprve sestavuje. Tady se upravují hlavičky, adresa nebo tělo. Lze zrušit.
  • htmx:confirm: jen u prvku s hx-confirm. Slouží pro vlastní potvrzovací dialog místo window.confirm, který pokračuje přes detail.issueRequest() nebo detail.dropRequest().
  • htmx:before:request: těsně před odesláním. Hodí se pro stav tlačítka a hlášení o odesílání. Lze zrušit.
  • htmx:before:response: dorazily hlavičky, tělo ještě ne. Zrušením odpověď zahodíte. Lze zrušit.
  • htmx:after:request: tělo je přečtené a hx-head už hlavičku sloučil, výměna ještě neproběhla. Lze zrušit.
  • htmx:response:error: stav je 400 nebo vyšší. Výměna přesto pokračuje.
  • htmx:error: výpadek sítě nebo výjimka při zpracování.
  • htmx:finally:request: vždy jako poslední, i po chybě a zrušení. Patří sem úklid.

Výměna obsahu:

  • htmx:before:swap: spouští se na prvku, který poslal požadavek. Adresa je už nová, obsah ještě starý. V detail.tasks jsou připravené výměny. Lze zrušit.
  • htmx:before:viewTransition: jen se zapnutým přechodem. Přes detail.task lze přidat práci, která se stihne do nového snímku.
  • htmx:before:settle, htmx:after:settle: spouští se na cíli výměny, nový obsah je v DOM. Hodí se pro nastavení fokusu.
  • htmx:after:process: htmx zpracoval atributy v novém obsahu. Na tuto událost navazuje htmx.onLoad(), který běží i při prvním načtení.
  • htmx:after:viewTransition: přechod doběhl.
  • htmx:after:swap: spouští se na prvku, který poslal požadavek. S přechodem přijde až po jeho konci, tedy později než htmx:after:settle.
  • htmx:finally:swap: konec výměny, i po chybě.
  • htmx:before:cleanup, htmx:after:cleanup: spouští se na prvku, který htmx odstraňuje z DOM.

Historie (spouští se na document):

  • htmx:before:history:update: chystá se zápis adresy. Přes detail.history.type lze změnit push na replace. Lze zrušit.
  • htmx:after:history:push, htmx:after:history:replace, htmx:after:history:update: adresa je zapsaná. Přichází to ještě před výměnou obsahu.
  • htmx:before:history:restore: krok zpět nebo vpřed, v detail.path je cílová adresa. Zrušením stránku nestáhnete znovu.

Sloučení hlavičky z rozšíření hx-head (spouští se na <body>):

  • htmx:head:before:merge: zrušením se hlavička z odpovědi nepoužije.
  • htmx:head:before:add, htmx:head:before:remove: týká se jednotlivé značky, zrušením ji necháte být.
  • htmx:head:after:merge: hlavička je sloučená.

Štítky Backend, PHP, JavaScript

Sdílet LinkedIn Facebook

Potřebujete víc než článek?

Stačí pár vět o tom, co řešíte.