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

Navigace v Symfony s htmx

Navigace bez načítání celé stránky v Symfony s htmx 4: boost a morph, prefetch, View Transitions, error pages, krok zpět s obnovou posunu, lazy loading a SEO.

díl 1/3 13 min čtenívydáno 11/2026

V tomto článku 12 kapitol
  1. Principy
  2. Instalace
  3. Navigace
  4. Prefetch
  5. View Transitions
  6. Error pages
  7. Krok zpět a posun
  8. Lazy loading
  9. SEO
  10. Měření
  11. Testy a nasazení
  12. Kontrolní seznam

První díl seriálu se týká navigace: jak z webu v Symfony udělat stránky, mezi kterými se přechází bez načítání celé stránky, a na co si dát pozor. Formuláře jsou ve druhém dílu, Alpine.js ve třetím. Platí pro htmx 4.

Principy

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 i security zůstávají v Symfony.

Každá interakce má stejný průběh: trigger → request → response → výběr části odpovědi (hx-select) → swap do DOM → settle → zápis do historie. Atributy určují, co se v jednotlivých krocích stane.

Předem je dobré vědět:

  • 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í.
  • Extensions se aktivují importem, žádný atribut na ně není potřeba.
  • Události mají tvar htmx:fáze:akce, například htmx:before:swap. Listener s překlepem v názvu se tiše nespustí.
  • Swapuje se všechno kromě 204 a 304, včetně error pages.
  • Historie neukládá kopie stránek. Krok zpět stránku znovu stáhne ze serveru.
  • Morph je přímo v jádře.

Atributy, události a hlavičky v tomto seriálu jen vyjmenujeme u konkrétních situací. Popis všech je v referenci htmx 4.

Instalace

bash
npm install htmx.org@4

Vstupní soubor je stejný pro Vite i Webpack Encore:

javascript
import htmx from 'htmx.org'
// Extensions 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 po vyhodnocení modulu. Extensions i listenery registrujte hned v modulu.
  • Skripty patří do <head>. Při morphu obsahu <body> by se skript na konci stránky choval jako obyčejný prvek.

Základní nastavení

Většině webů stačí jedno nastavení na <body>:

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

Controllery se nemění a vracejí celé stránky. Stejně může běžet i hledání s filtry a stránkováním jako obyčejné GET formuláře a odkazy, bez fragmentů a bez čtení hlaviček na serveru.

hx-swap:inherited zdědí i prvky s vlastním hx-get nebo hx-post, ty proto mají mít vlastní hx-swap.

Když ve stránce musí zůstat něco mimo swap, například prvky vložené cizím skriptem, swapujte jen hlavní kontejner:

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ž target není <body>, hx-head hlavičku jen doplňuje a titulky i meta tagy se hromadí.
  • Target uveďte selektorem, ne target:this.
  • hx-target ani 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 boostovat a co ne

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á na prohlížeči.

hx-boost="false" patří na:

  • odkazy do administrace nebo jiné části s vlastními assety,
  • stránky s úplně jiným layoutem, například přihlášení bez hlavičky a patičky,
  • přihlašovací formulář a odkazy, které něco mění (odhlášení přes GET),
  • přepínač, který řeší stav jen v prohlížeči.

Morph mezi nesourodými layouty je zbytečné riziko, plné načtení je spolehlivější.

Morph a identita prvků

Morph páruje prvky podle tagu a id. Prvek bez id se sloučí s jakýmkoli prvkem se stejným tagem 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í.

Osvědčené je dát hlavnímu kontejneru obsahu id podle routy:

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

Stejná routa se morphne a stav zůstane, jiná routa obsah čistě vymění. Navigace, sidebar a patička zůstanou mimo kontejner a jejich stav přežije přechod. Na co si dát pozor:

  • Kontejner bez id nebo se společným id přenáší stav mezi nesouvisejícími prvky. Text z pole search se objeví v poli email na další stránce, heslo se ukáže čitelně v textovém poli (morph změní type, hodnotu nechá) a listener pověšený přímo na tlačítko „Smazat“ se spustí po kliknutí na nové tlačítko „Sdílet“.
  • Stejná vnitřní id pod různými kontejnery. Když má prvek uvnitř kontejneru na obou stránkách stejné id, morph ho přenese i se stavem do nového kontejneru a komponenta v něm může přijít o stav. id uvnitř stránky proto volte tak, aby se mezi různými stránkami neopakovala, pokud prvek nemá přechod přežít úmyslně.
  • Formuláře mají mít vlastní id, u přihlašovacího formuláře je to nutnost.
  • Listenery věšte přes delegaci na document, nebo je před swapem odstraňte.
  • 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 (výchozí pole rozšiřte, nepřepisujte), nebo stav zjišťujte přes knihovnu, například Chart.getChart(canvas).
  • Morph zachovává hodnoty polí, takže se nehodí na reset formuláře (viz díl 2).
  • Názvy atributů musí projít přes setAttribute(). Morph jím kopíruje atributy a WebKit odmítne název, který parser HTML přijme, například @click z Alpine.js. Morph pak skončí v půlce bez chyby v konzoli. Podrobnosti jsou ve třetím dílu.

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

Head, titulek a jazyk

Extension hx-head sloučí <head> podle přesné shody tagů. Titulek, description, canonical i JSON-LD se po přechodu vymění.

  • Analytiku 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 swapuje jen obsah <body>. Po přepnutí jazyka by zůstal starý jazyk pro čtečky i překladače. Přepínači jazyka 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 swapu, se zapnutými View Transitions až po doběhnutí přechodu. 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 nastaví hned.
  • CSS nenačítejte z lazy importovaného JavaScriptu. Vite ho vloží do <head>, další přechod ho jako neznámý odstraní a znovu se nepřidá. Lazy načítejte jen JavaScript.

Rozbalovací menu na :hover a :focus-within zůstane po kliknutí na odkaz v něm otevřené, protože odkaz si fokus podrží i po přechodu. Při plném načtení stránky se fokus ztratí sám, s htmx je potřeba ho sundat:

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

Prefetch

Extension hx-preload stáhne stránku už při mousedown nebo touchstart. Než uživatel pustí tlačítko, je obsah na cestě nebo hotový. Pro boostované odkazy stačí extension načíst, atribut je potřeba jen pro změnu chování:

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

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

Akce, které něco mění, z prefetche vyjměte. Prefetch nerozlišuje tlačítka myši a spustí se i pravým klikem, prostředním klikem nebo začátkem posunu na mobilu. Odkaz přes GET by tak provedl akci, kterou uživatel nechtěl:

  • odhlášení,
  • přidání do košíku nebo do oblíbených,
  • smazání, archivace, označení jako přečtené,
  • hlasování, potvrzení nebo zrušení odběru,
  • přepnutí jazyka nebo měny uložené do session,
  • export, který se na serveru drahý generuje,
  • cokoli, co vyzvedne flash zprávu.

Nejčistší je takové akce dělat formulářem přes POST s CSRF tokenem, prefetch se POST netýká. Když musí zůstat odkazem, vyjměte ho: hx-preload="none" vypne jen prefetch a boost nechá, hx-boost="false" vypne obojí. Odhlášení a přepnutí jazyka patří spíš k hx-boost="false", protože mění celou stránku.

Další důsledky:

  • Počítadla zobrazení mírně nadhodnocují, započítá se i rozmyšlené stisknutí. Prefetch na serveru nefiltrujte: po kliknutí bývá jediným requestem, takže by zmizela skoro všechna skutečná zobrazení. Na seznamu, kde gesto posunu často začíná na odkazu, zužte triggery přes hx-preload:inherited="mousedown".
  • Výchozích 5 sekund platnosti neprodlužujte tam, kde je osobní stav. 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.
  • Prefetch na hover se nevyplatí. Extension nemá prodlevu, takže přejetí přes menu vyrenderuje všechny stránky za ním. Vlastní prodleva vedla k dvojitým requestům a proti mousedown přinesla jen desítky milisekund.

View Transitions

htmx.config.transitions = true zapne View Transitions. S výchozí délkou trvá přechod asi 300 ms, což na webu s prefetchem působí pomaleji, než web je. Zkraťte všechny tři pseudo-elementy, délku celého přechodu určuje group:

css
::view-transition-group(root),
::view-transition-old(root),
::view-transition-new(root) {
    animation-duration: 100ms;
}
  • Při prefers-reduced-motion přechody vůbec nezapínejte. Samotný snímek stránku na dobu přechodu zmrazí, i když je animace v CSS vypnutá.
  • Prvek s vlastním view-transition-name se animuje zvlášť. Podtržení aktivní položky menu jako samostatný <span> s vlastním jménem tak mezi stránkami plynule přejede, včetně změny šířky. Jméno musí být v dokumentu jedinečné, dvě stejná jména 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. Smooth scroll zapínejte jen u konkrétních odkazů.

Error pages

htmx swapne 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ý layout.
  • V prostředí dev a test se nevykreslí, protože běží kernel.debug. Zkoušejte je na buildu pro prod.
  • U varianty s #app by odpověď bez #app smazala obsah a zůstala by prázdná stránka. Stane se to při 502 z proxy během nasazení, při fatální chybě PHP nebo u fallback error page Symfony. Pojistka 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á session

Když session expiruje, Symfony přesměruje na login a fetch redirect sám následuje. Pro boostovaný odkaz s loginem ve stejném layoutu to funguje bez úprav. Když má login jiný layout nebo se načítá fragment s vlastním targetem, vložila by se přihlašovací stránka do cizího místa. 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 requestu jako cíl po přihlášení. U fragmentu přes GET by se uživatel po přihlášení dostal na holý fragment, v této větvi proto cíl smažte přes TargetPathTrait::removeTargetPath().

Krok zpět a posun

htmx si při přechodu neukládá kopii opuštěné stránky, takže ji krok zpět stahuje ze serveru znovu. Back/forward cache prohlížeče nepomůže, funguje jen při přechodu mezi dokumenty.

  • V prohlížeči s Navigation API htmx navigaci zachytí, stránku stáhne a swapne. Ve Firefoxu a prohlížečích bez tohoto API stejnou práci spouští popstate.
  • Request nese jen HX-History-Restore-Request, chybí HX-Request i HX-Boosted. Server tak vrátí celou stránku bez úprav.
  • Swap jde přes outerSync, ne přes morph. Stav komponent a rozepsaná pole se ztratí.

Z toho plyne:

  • Stav, který má přežít krok zpět, patří do URL. Filtr, stránka i hledání musí být v adrese. Fragment s hx-push-url musí mít adresu, na které server vrátí celou stránku.
  • Widgety se musí umět zrušit a vytvořit znovu. Grafy rušte v htmx:before:swap a vytvářejte v htmx.onLoad.
  • Úklid stavu mimo swap patří taky do htmx:before:swap, například odstranění třídy, která zamyká posun při otevřeném menu.
  • Bez requestu na server jde krok zpět s extension hx-history-cache, které stránky ukládá do sessionStorage, takže Zpět nepošle žádný request. Daní je, že data po návratu nemusí být čerstvá.

Scroll restoration

Prohlížeč obnoví posun až po skončení zachycené navigace, takže se stránka nejdřív vykreslí nahoře a pak skočí dolů. Firefox to má naopak: posune ještě starý obsah a z delší stránky na kratší se posun po swapu ořízne. Řešení je pamatovat si posun 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 zachycení 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 přes adresní řádek událost navigate nespustí.
        window.addEventListener('pagehide', () => {
            positions[navigation.currentEntry.key] = window.scrollY
            store(positions)
        })
    }

    // Musí se registrovat dřív než listener 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 swap 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 provázaná s historií jsou ve třetím dílu.

Lazy loading

Vyplatí se u obsahu, který je velký nebo náročný na přípravu a většina lidí ho neotevře, například graf vývoje ceny, historie objednávek nebo náhled dokumentu ve sbaleném oddílu:

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 neumí a chyba při parsování zastaví zpracování zbytku stránky.
  • Další užitečné triggery: load (hned po vložení), revealed (když se prvek dostane do viewportu), intersect s prahem.
  • Když hodnota z adresy určuje šablonu nebo soubor, omezte ji přes requirements.
  • Rezervujte místo pro obsah, ať se stránka po načtení neposune.
  • Fragment bez osobních údajů může být veřejně cachovaný.

JavaScript pro nový obsah

  • Widgety inicializujte v htmx.onLoad(callback). Běží při prvním načtení i po každém swapu, včetně kroku zpět. Inicializace musí být opakovatelná.
  • htmx:after:swap se spouští na prvku, který request poslal, ne na targetu. Na inicializaci nového obsahu se nehodí.
  • Těžké knihovny (zvýrazňování kódu, mapy, grafy) načítejte přes import(), až když je na stránce jejich prvek. V hlavním bundlu zůstane jen podmínka:
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 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 totéž co uživatel při prvním načtení.

bash
curl -s https://example.com/sluzby | grep -E '<title>|rel="canonical"|<h1'
curl -sI https://example.com/neexistuje | head -1
  • 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 lazy loading načítejte jen doplňky. Text, který má být k nalezení, nechte v HTML, i když je ve sbaleném <details>. Řada AI crawlerů JavaScript vůbec nespouští.
  • Titulek, description, canonical a hreflang jsou v odpovědi každé adresy, hx-head je pak vymění i uživateli.
  • Skutečné status kódy. 404 musí být 404, přesun 301. Error page se stavem 200 by se dostala do indexu.
  • 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.
  • Core Web Vitals stojí hlavně na prvním načtení, ne na přechodech. Hlídejte velikost bundle a rychlost odpovědi serveru, protože přes něj jdou prefetch i krok zpět.

Stránkování a filtry z pohledu SEO popisuje díl 2.

Měření

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

  • Vlastní pageview odešlete po htmx:after:swap, jen když se změnila adresa. Otevřené okno ani odeslaný formulář se pak nepočítají.
  • Titulek je v tu chvíli nový, hx-head ho sloučil už při odpovědi. Události po zápisu do historie přicházejí ještě před swapem, kdy stránka ukazuje starý obsah.
  • Google Tag Manager zvládne přechody přes trigger History Change. S vlastním odesláním navíc máte dvě zobrazení.
  • Krok zpět se počítá jako zobrazení.
  • Skript analytiky označte hx-preserve="true".

Testy a nasazení

  • Před aktualizací htmx vyzkoušejte přechod a odeslání formuláře i ve starším prohlížeči a projděte výskyty htmx: v šablonách.
  • V end-to-end testech čekejte na htmx:finally:request a listener 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í.
  • Krok zpět testujte v Chromu i ve Firefoxu, jde tam jiným kódem. Zkoušejte s View Transitions i s prefers-reduced-motion.
  • Přechody zkoušejte i ve WebKitu. Chyba morphu tam nemusí nic vypsat, poznáte ji podle toho, že po kliknutí zůstane část staré stránky.
  • Error pages testujte na prod a jednou i výpadek bez vaší šablony, například 502 z proxy.

Kontrolní seznam

  • hx-head a hx-preload importované za jádrem, skripty v <head>, jednorázové s hx-preserve="true"
  • <body> s hx-boost:inherited a morphem, nebo #app s hx-head="merge" a hx-history-elt
  • Hlavní kontejner s id podle routy, vnitřní id se mezi stránkami neopakují
  • hx-boost="false" na odhlášení, přihlášení, administraci a jiné layouty
  • Akce, které něco mění (košík, smazání, hlasování…), přes POST, jinak hx-preload="none"
  • <html lang> po přepnutí jazyka odpovídá stránce
  • AuthenticationEntryPoint vrací u htmx HX-Redirect, pokud má login jiný layout nebo jsou za přihlášením fragmenty
  • Error pages s celým layoutem, u #app pojistka na prázdný výběr
  • Zkrácené View Transitions, vypnuté při prefers-reduced-motion, žádný globální scroll-behavior: smooth
  • Modul pro scroll restoration registrovaný před spuštěním htmx
  • Stav, který má přežít Zpět, je v URL
  • Widgety v htmx.onLoad, opakovatelná inicializace, klientské příznaky v morphIgnore
  • SEO: curl vrací celou stránku, odkazy jsou <a href>, hlavní obsah v prvním HTML, fragmenty s noindex
  • Pageview po htmx:after:swap, ne po změně historie
  • Krok zpět otestovaný v Chromu i ve Firefoxu, přechody i ve WebKitu, error pages na prod

Š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.