Skip to content
iD-SIGN
Contact us

Symfony with htmx made easy and done right

A complete guide to Symfony and htmx 4: navigation and prefetch, the Back button, forms, loading parts of a page and SEO. Including the mistakes that only show up in production.

26 min readpublished 11/2026

In this article 12 chapters
  1. How htmx works
  2. Installation
  3. Navigation and prefetch
  4. Back and forward
  5. Forms and validation
  6. Loading parts of a page
  7. SEO
  8. Measurement
  9. Testing and deployment
  10. Checklist for a new project
  11. When not to use htmx
  12. Overview of attributes, headers and events

This guide sums up what we learned building several projects on Symfony and htmx, from content websites to an application with accounts, purchases and payments. The mistakes in it are not theoretical: we really made most of them, or a code review caught them.

The guide applies to htmx 4 and Symfony 8. Combining htmx with client-side libraries such as Alpine.js will be covered in a separate article.

How htmx works

The server returns finished HTML and htmx inserts it into the page. You need no API for the frontend and no second layer of state and validation. Templates, forms and security stay in Symfony.

Every interaction goes through the same cycle: trigger → request → response → selecting part of the response → swap in the DOM → settle → writing to history. Attributes decide what happens at each step. An overview of them, along with swap styles, headers and events, is at the end of the article.

Rules worth knowing up front

  • Inheritance is explicit. Descendants only receive an attribute with the :inherited suffix. Without it, links are not boosted and no error appears.
  • Extensions are activated by importing them. They need no attribute.
  • Events are named htmx:phase:action, for example htmx:before:swap. A listener with a typo in the name silently never runs.
  • Everything except 204 and 304 gets swapped, including error pages.
  • History keeps no copies of pages. Going back downloads the page from the server again.
  • Morph is part of the core. It needs no extension.

Installation

bash
npm install htmx.org@4

The entry file works the same with Vite through Symfony Reprise and with Webpack Encore:

javascript
import htmx from 'htmx.org'
// Extensions register on the global htmx, so import them after the core.
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()
  • You do not need to call htmx.initialize(), htmx starts by itself once the module has been evaluated. Register extensions and listeners right in the module, not later.
  • Put scripts in <head>. Morphing the content of <body> would treat a script at the end of the page as an ordinary element.

Basic setup

For most projects, one setting on <body> is enough:

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

Controllers do not change and keep returning whole pages. Even search with filters and pagination can run this way, as plain GET forms and links, without a single fragment and without reading any headers on the server.

hx-swap:inherited also reaches elements with their own hx-get or hx-post. Those should therefore always have their own hx-swap.

Use the second variant when something on the page has to stay out of the swap, for example elements inserted by a third-party script. Morphing the whole <body> would remove them, because they are not in the response.

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" is required here. When the target is not <body>, hx-head only adds to the head, and titles and meta tags pile up.
  • Give the target as a selector, not as target:this.
  • Do not put hx-target and hx-select on #app, all descendants would inherit them.
  • Without hx-history-elt, going back would replace the whole <body>.

What boost takes over and what to exclude

Boost applies to links and forms on the same origin. It skips links with download, with a target other than _self, and links starting with #. A click with Ctrl, Cmd or Shift is left to the browser.

Put hx-boost="false" on:

  • links to an admin area or another part with its own assets,
  • pages with a completely different layout, for example a login without a header and footer,
  • the login form and links with a side effect,
  • a filter switch that handles its state in the browser.

Morphing between unrelated layouts is a needless risk. A full page load is simpler and more reliable.

Morph and element identity

Morph pairs elements by tag and id. An element without an id merges with any element with the same tag in the same place. An element with an id merges only with an element with the same id, otherwise it is inserted as new and the old one disappears. That is why the id of the main page container matters.

  • Give the same id only to pages of the same type, for example two article details. Merging makes sense there and state is kept. Pages with a different structure should have a different id. A simple substitute is an id based on the route, two routes with the same structure merely lose merging.
  • The id belongs on the main container, not on <body>. innerMorph merges only the children, so it neither reads nor changes the id or class of <body>. A container without an id behaves the same as a container with a shared id.
  • Merging carries state between unrelated elements. With a shared container id, text from a search field appears in an email field on the next page, and a password appears in plain sight in a text field, because morph changes the type but keeps the value. A handler for a "Delete" button attached directly to the element runs when the new "Share" button is clicked.
  • Forms should have their own id. With different form ids, the value does not carry over even under a shared container. For a login form this is a must.
  • Attach listeners through delegation on document, or remove them before the swap.
  • An element that should survive the transition has the same id on both pages. It carries over with its value, even under containers with different ids.
  • Morph removes attributes that are not in the response. A flag such as data-map-initialized set in the browser disappears and the widget initializes a second time. Add it to htmx.config.morphIgnore (extend the default array, do not overwrite it), or read the state from the library, for example Chart.getChart(canvas).
  • Morph keeps field values. It is not suitable for resetting a form, see the section on forms.
  • Attribute names must pass through setAttribute(). Morph copies attributes with it, and browsers with older name validation, for example WebKit, reject a name like @click, the Alpine.js shorthand. The merge stops halfway, the page is left with a mix of new and old content and htmx swallows the exception, so nothing shows up in the console. The HTML parser accepts such an attribute, so everything works in Chrome. Use the full form x-on:click.

Exclude an element with the hx-morph-skip attribute. hx-morph-skip-children updates only its attributes.

The page head and language

The hx-head extension merges <head> by exact match of tags. The title, description, canonical and JSON-LD are therefore replaced after a transition.

  • Mark analytics and other one-time scripts in the head with hx-preserve="true", otherwise they would be removed and run again on every transition.
  • <html lang> does not change, because htmx swaps only the content of <body>. After switching language, the old language remains for screen readers and translators. Give the switcher hx-boost="false", or set lang from the response:
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 writes the title only after the swap. With View Transitions on, the write comes only after the transition has finished, and a second quick click can then leave the title of the first page in the tab. ignoreTitle:true in hx-swap leaves the title to hx-head alone, which sets it immediately.
  • Do not load styles from lazily imported JavaScript. Vite inserts them into <head>, the next transition removes them as unknown, and they are not added again. Load only JavaScript lazily.

Prefetch

The hx-preload extension downloads the page already on mousedown or touchstart. By the time the button is released, the content is on its way or ready. For boosted links it is enough to load the extension, the attribute is needed only to change the behaviour:

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

A prefetch is a real request and does not use the HTTP cache, so you do not need to set Cache-Control for it. It applies only to GET. It follows that:

  • A GET with a side effect must have hx-boost="false". Prefetch does not distinguish mouse buttons. A logout link over GET would log the person out on a right-click or at the start of a scroll on a phone. This applies to anything a GET changes, for example picking up a flash message.
  • View counters overcount slightly, because a press that was reconsidered counts too. Do not filter prefetches on the server: after the click the prefetch is usually the only request, so nearly all real views would disappear. On a list where a scroll gesture often starts on a link, narrow the triggers with hx-preload:inherited="mousedown".
  • Do not extend the validity where personal state is involved. The default 5 seconds is reasonable. Morph keeps a link without an id along with its downloaded response, so a longer validity would show an old balance after a purchase. On a purely content website it can be extended, for example mousedown timeout:30s.
  • We rejected triggering on hover. The extension has no delay, so moving across a menu renders every page behind it. Our own delay led to duplicate requests and gained only tens of milliseconds over mousedown.

Transitions between pages

htmx.config.transitions = true turns on View Transitions. They cost time: with the default duration we measured about 300 ms per transition, which on a website with prefetch makes it feel slower than it is. Shorten all three parts, the group determines the length of the whole transition:

css
::view-transition-group(root),
::view-transition-old(root),
::view-transition-new(root) {
    animation-duration: 100ms;
}
  • With reduced motion, do not turn transitions on at all. The snapshot alone freezes the page for the duration of the transition, even if the animation is disabled in CSS.
  • The same view-transition-name on two elements cancels the transition.
  • Do not use a global scroll-behavior: smooth. The page would visibly scroll after every transition and every step back. Turn on smooth scrolling only for specific links.

Error pages

htmx also inserts an error response, so a boosted link to a missing page shows your 404 page.

  • Templates in templates/bundles/TwigBundle/Exception/ need the complete layout.
  • They are not rendered in the dev and test environments, because kernel.debug is on there. Try them on a prod build.
  • With the #app variant, a response without #app (502 from a gateway during a deployment, a PHP fatal error) would delete the content and leave a blank page. A safeguard that hands navigation to the browser helps:
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)
    }
})

Expired login

When a login expires, Symfony redirects to the login page and fetch follows the redirect by itself. What happens next depends on the request and on the layout of the login page:

  • Boosted link, login in the same layout as the rest of the website: works without changes. The login page merges like any other and its address is written to history.
  • Boosted link, login with a different layout: morphing between unrelated pages is a needless risk, and with the #app variant the login page may not even have that element.
  • Fragment with its own target: the login page is inserted into the target, whatever its layout.

In the last two cases it helps when AuthenticationEntryPoint forces a full page load for htmx:

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

Before this call, Symfony saves the request address as the target after login, and it does not consider an htmx request to be XHR. From the framework's code it follows that after a fragment requested over GET, the person would end up on the bare fragment after logging in. If you have fragments behind a login, remove the saved target in this branch with TargetPathTrait::removeTargetPath().

Back and forward

This is where htmx 4 differs most from what people expect. It does not keep a copy of the page you leave, so going back has to download it from the server again. The browser will not do it instead: its back/forward cache works only when moving between documents, and a boosted transition does not change the document.

What happens

  • In a browser with the Navigation API, htmx intercepts the navigation, downloads the page and swaps it. In Firefox and browsers without this API, popstate starts the same work.
  • The request carries only HX-History-Restore-Request. The HX-Request and HX-Boosted headers are missing, so the server returns the whole page with no adjustment.
  • The swap uses outerSync, not morph. Children are replaced, so component state and half-filled fields inside are lost.
  • During a restore, hx-head merges the head from the response, so the title is correct.

What follows from this

  • State that should survive going back belongs in the address. A filter, page or search must be in the URL. A fragment with hx-push-url must have an address at which the server returns the whole page.
  • Data is fresh after returning, but serving a step back is an ordinary request, so speed depends on the server. The hx-history-cache extension changes this behaviour, but we have no experience with it.
  • Widgets must be able to be destroyed and created again. Destroy charts in htmx:before:swap and create them in htmx.onLoad.
  • Cleaning up state outside the swap also goes in htmx:before:swap, for example removing a class that locks scrolling while a menu is open.

Scroll position

The browser restores the scroll position only after the intercepted navigation has finished. The page therefore first renders at the top and then jumps down. Firefox does the opposite: it scrolls the old content, and after the swap the position is clipped when going from a longer page to a shorter one. The solution is to remember the position yourself, by the key of the history entry:

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) { /* private window: the position only survives until a reload */ }
}

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

    const positions = load()
    let pending = null

    // The same condition under which htmx listens to popstate instead of intercepting navigation.
    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)

        // Leaving through the address bar does not fire the navigate event.
        window.addEventListener('pagehide', () => {
            positions[navigation.currentEntry.key] = window.scrollY
            store(positions)
        })
    }

    // Must be registered before the htmx listener, otherwise scroll: 'manual' comes too late.
    navigation.addEventListener('navigate', (event) => {
        positions[navigation.currentEntry.key] = window.scrollY
        store(positions)

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

        // Entries with the same address come from a modal window: the page does not change and no swap follows.
        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
    }

    // With a View Transition, htmx:after:swap comes only after the transition. Scrolling
    // therefore belongs at the end of its task, so the new snapshot is already in position.
    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)
}

The module skips browsers without the Navigation API. There the browser restores the position by itself, with the jump described above.

People expect Back to close an open window. htmx would instead download the page again. A window therefore adds its own history entry with the same address, and the state of all windows is kept in one place tied to history:

javascript
let entryPath = null

const modal = {
    current: null,

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

// A step back from the window entry only closes the window, the page is not downloaded again.
document.addEventListener('htmx:before:history:restore', (event) => {
    const isLeavingEntry = entryPath === event.detail.path
    entryPath = null
    modal.current = null
    if (isLeavingEntry) event.preventDefault()
})

// Forward opens the window again. htmx leaves such an entry alone, because it finds no state of its own there.
window.addEventListener('popstate', (event) => {
    if (!event.state?.modal) return
    entryPath = location.pathname + location.search
    modal.current = event.state.modal
})

// A link from the window: the new page replaces the window entry, otherwise Back would restore only the address.
document.addEventListener('htmx:before:history:update', (event) => {
    if (entryPath !== null) event.detail.history.type = 'replace'
    entryPath = null
    modal.current = null
})
  • Closing with the cross or Escape calls history.back(), so that the entry does not remain in history.
  • After a step in history, the Navigation API returns focus to <body>. Return focus to the original button only in navigatesuccess, in Firefox after popstate.
  • Use the native <dialog> and showModal() for the window. You get the modal layer, Escape and focus trapping from the browser.

Forms and validation

Controller

A boosted link sends HX-Request too. Anyone who checks only this header sends the link a fragment instead of a page. The helper method therefore checks both:

php
private function isFragmentRequest(Request $request): bool
{
    return $request->headers->has('HX-Request')
        && !$request->headers->has('HX-Boosted');
}

#[Route('/contact', 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()) {
        // Save and send.

        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() sets status 422 only when you pass it a FormInterface. With $form->createView() it stays 200 and you have to set the status yourself.

Template

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',
    },
}) }}
  • Keep action and method, without JavaScript the form is submitted normally.
  • hx-swap="outerHTML" and not morph. Morph keeps field values, so the form would stay filled in after submitting.
  • hx-sync="this:drop" drops a second submission.
  • novalidate if the server checks for errors. Firefox for Android does not display the browser's messages, so an empty form would silently do nothing after the click. Keep the required attribute on fields for screen readers.

Error responses

A validation 422 is inserted without configuration. For a fragment, however, you do not want to insert a whole error page into the form. hx-status looks for the exact code first and only then for a wildcard, so 422 must be listed separately. Otherwise validation errors silently disappear:

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' is not enough. hx-head merges the head of the response before hx-status is evaluated, and htmx sets the title even when it swaps nothing. An error page would thus overwrite the title and head of the current page. Allow the head and title only for a transition to a whole page. With the #app variant, compare the target with that element:

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

A failure must not throw away text. On a form where someone has been writing for a long time, discard the 5xx response and insert a phone number and e-mail address below the button. Do the same when the network fails:

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

Redirects

  • A target outside the current page (a payment gateway, a different layout): return 200 with HX-Redirect. htmx does not process headers on a 3xx response, and fetch follows the redirect by itself.
  • hx-post with an ordinary redirect inserts the whole target page into hx-target. A whole page inside a small box is the typical symptom.
  • A boosted form handles a redirect and writes the final address to history. If the target has a different layout, however, choose hx-boost="false". This applies especially to login, where the authenticator redirects after success.

Forms in modal windows

Render a form in a window directly into the page, do not load it lazily. You avoid timing problems and problems with processing hidden elements. The cost is that the CSRF token may expire on a page left open for a long time, so prepare a clear message for this case. In Symfony you set it with csrf_message and translate it in the validators domain.

Accessibility

  • After a swap, focus is often on <body>. After a 422 put it on the first invalid field, after submitting on the whole confirmation. Do both in htmx:after:settle.
  • While submitting, use aria-disabled on the button, not disabled, which would drop focus to the start of the page.
  • Announce the submitting state in a prepared element with role="status".

Loading parts of a page

Choose in this order and do not start with the last one:

  1. The whole page through boost. No code on the server. Enough for most listings, pagination and filters.
  2. A separate route for the fragment. One address, one representation. Suitable for lazy loading.
  3. The same address, two representations. Only when the whole page is really too expensive.

Lazy loading

It pays off for content that is large or expensive to prepare and that most people never open. Typically a price history chart, an order history or a document preview in a collapsed section. The page is not held up by something most visitors do not need.

xml
<details>
    <summary>Price history</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('/product/{id}/price-history', 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;
}
  • A multi-word selector in hx-trigger must be in quotes. htmx 4 does not understand parentheses, and an error during processing stops the rest of the page from being processed.
  • When a value from the address determines a template or file, restrict it with requirements, so that nothing else can be loaded through it.
  • Reserve space for the content, so that the page does not shift after loading.
  • You can cache a fragment without personal data as public.

Pagination with a fragment

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">Next</a>

The link needs both href and hx-get. Without href a crawler will not find it, and it does not work without JavaScript either. The controller decides through 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 protects against a cache returning a fragment instead of the whole page. Whoever gets the same result from the whole page through hx-select deals with one address and one representation, and the server does not have to distinguish anything. The price is transferring a larger response.

Updates outside the target

hx-swap-oob updates other places with one response, for example the number of items in a cart or a message about a discount code.

  • When nothing remains in the response after OOB elements are extracted, htmx skips the main swap. An empty response therefore does not clear an old error. hx-swap="innerHTML swapEmpty:true" helps.
  • Wrap table rows and list items in <template>, otherwise the HTML parser discards them.

JavaScript in loaded content

  • Initialize widgets in htmx.onLoad(callback). It runs on the first load and after every swap, including going back.
  • htmx:after:swap fires on the element that sent the request, not on the target. It is not suitable for initializing new content.
  • Initialization must be repeatable.
  • Load heavy libraries through import(), only when their element is on the page.

SEO

htmx is invisible to search engines as long as one rule holds: every address returns the whole page without JavaScript. A crawler does not send HX-Request, so it gets what a person gets on the first load.

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

Rules

  • Links are <a href>. A crawler does not follow hx-get on a button or on a <div>.
  • What should be found is in the first HTML. A crawler does not click, scroll or open a section. Load only extras through htmx, such as a chart or a history. Keep text that should be found in the HTML, even in a collapsed <details>. Many AI crawlers do not run JavaScript at all.
  • The title, description, canonical and hreflang are in the response of every address. hx-head then replaces them for the user too.
  • Real statuses. A 404 must be a 404 and a move a 301. An error page with status 200 would end up in the index.
  • Every page of a listing has its own canonical. The second page has an address with ?page= and a title with the number. Google explicitly advises against using the first page as the canonical one. You do not have to put further pages in the sitemap, links lead to them.
  • hreflang on further pages of a listing depends on the content. When language versions have the same items in the same order and differ only in translation, link to the same page in each language. When the number of items differs, leave hreflang out on further pages, because a link to a page that does not exist is worse than none.
  • Filtered and sorted variants of a listing that are not meant to be landing pages should be excluded from the index with noindex, as Google recommends. A canonical address pointing to the listing without the parameter is only a hint to the search engine, and for a page with different content it may not be accepted.
  • Pagination has real links. Infinite loading without addresses is a dead end for crawlers.
  • Fragments do not belong in the index. Their own routes get X-Robots-Tag: noindex and are not in the sitemap. Do not block them in robots.txt, the crawler would not read the header.
  • Form POST routes and search results have no place in the sitemap.

Speed

Prefetch and morph speed up transitions, but Core Web Vitals in Search Console rest mainly on the first load. htmx is small, but keep an eye on the bundle anyway and load heavy libraries lazily. Watch the server's response time too, because prefetch and going back both go through it.

Measurement

An htmx transition is not a page load, so analytics does not count the views by itself.

  • Send your own measurement after htmx:after:swap, only when the address has changed. An opened window or a submitted form is then not counted as a view.
  • The title is new at that point, because hx-head already merged it when the response arrived. An event after writing to history comes before the content is swapped, so at that moment the page shows the old content.
  • Google Tag Manager handles transitions through the History Change trigger. If you add your own sending on top of it, you get two views.
  • Going back counts as a view. Make sure that is what you want.
  • Mark the measurement script with hx-preserve="true".

Testing and deployment

  • Before every htmx update, try a transition and a form submission in an older browser too, and going back from a modal window on a freshly loaded page. Also go through the occurrences of htmx: in your templates.
  • In end-to-end tests wait for htmx:finally:request and attach the listener in the same script before the click. Waiting for the .htmx-request class to disappear right after a click can finish before the class has even appeared. After a swap, reload the DOM in the test client.
  • Test going back in both Chrome and Firefox, it runs through different code there. Try it with transitions and with reduced motion.
  • Try transitions in WebKit too, not just in Chrome. A morph failure may log nothing there, you can tell by part of the old page remaining after a click. On Linux, GNOME Web or MiniBrowser from WebKitGTK will do.
  • Test error pages on prod and once try an outage without your template too, for example a 502 from a proxy.
  • Watch out for a page left open during a deployment. hx-head compares tags literally. From the htmx code it follows that a script with a new hash is added on the next transition and runs alongside the old one. We have not measured this in production yet, but after a deployment with a larger change it is worth checking.

Checklist for a new project

  • The hx-head and hx-preload extensions imported after the core
  • <body> with hx-boost:inherited and morph, or #app with hx-head="merge" and hx-history-elt
  • All scripts in <head>, one-time ones with hx-preserve="true"
  • hx-boost="false" on logout, login, the admin area, different layouts and GET with a side effect
  • <html lang> matches the page after switching language
  • A fragment only with HX-Request && !HX-Boosted, with Vary on the same address
  • AuthenticationEntryPoint returns HX-Redirect for htmx if the login has a different layout or there are fragments behind a login
  • Error templates with the complete layout, for #app a safeguard against an empty selection
  • Transitions shortened and off with reduced motion, no global scroll-behavior: smooth
  • The scroll restore module registered before htmx starts
  • Modal window state in one place tied to history
  • State that should survive Back is in the address
  • Forms: FormInterface passed to render(), outerHTML, hx-sync, hx-status:422 with a 4xx wildcard, text protected on 5xx, focus after the swap
  • Widgets in htmx.onLoad, repeatable initialization, client-side flags in morphIgnore
  • Attributes without @ in their names, transitions tried in WebKit too
  • SEO: curl returns the whole page, links are <a href>, main content in the first HTML, fragments with noindex
  • Measurement after htmx:after:swap, not after a history change
  • Going back tested in Chrome and Firefox, error pages on prod

When not to use htmx

htmx suits cases where the server does most of the work: content websites, forms, accounts, admin areas. For an application that keeps a lot of state in the browser, such as an editor or offline work, a client-side framework is the better choice.

Overview of attributes, headers and events

Attributes you will use

  • hx-get, hx-post: send a request to the given address.
  • hx-trigger: sets the event that starts the request. The default is a click, for a form a submit.
  • hx-target: sets the element the swap applies to. The default is the element itself.
  • hx-swap: sets the swap style. The default is innerHTML.
  • hx-select: picks only part of the response by selector.
  • hx-boost: turns an ordinary link or form into an htmx request.
  • hx-push-url: writes the address to browser history.
  • hx-sync: handles concurrent requests, for example dropping a second submission.
  • hx-status:CODE: changes behaviour by response status, for example hx-status:422.
  • hx-swap-oob: in a response, marks an element to be swapped outside the main target.
  • hx-preserve: leaves an element unchanged when it is on both the old and new page.
  • hx-history-elt: sets the element swapped when going back. The default is <body>.

Swap styles

  • innerHTML, outerHTML: replace the content or the whole element. Useful when state should be thrown away.
  • innerMorph, outerMorph: merge new HTML into the existing DOM and keep focus, scroll position, animations and field values.
  • outerSync: merges the element's attributes and replaces its children. htmx uses it when going back.
  • none: inserts nothing.

Headers

  • HX-Request: sent by every ordinary htmx request, including a boosted link.
  • HX-Boosted: sent only by a boosted element.
  • HX-History-Restore-Request: sent by a page restore when going back.
  • HX-Redirect (in the response): forces a full page load. It works only with a 2xx response.
  • HX-Retarget, HX-Reswap, HX-Push-Url (in the response): let the server override the target, the swap or the address.

Events

Events bubble, so it is enough to attach listeners to document. Most carry the request context in event.detail.ctx (ctx.target, ctx.request, ctx.response, ctx.text). Where stated, event.preventDefault() cancels what follows. The lists are in the order in which the events arrive.

Request:

  • htmx:config:request: the request is still being put together. This is where headers, the address or the body are changed. Can be cancelled.
  • htmx:confirm: only on an element with hx-confirm. For a custom confirmation dialog instead of window.confirm, which continues through detail.issueRequest() or detail.dropRequest().
  • htmx:before:request: right before sending. Useful for button state and an announcement about sending. Can be cancelled.
  • htmx:before:response: the headers have arrived, the body has not. Cancelling discards the response. Can be cancelled.
  • htmx:after:request: the body has been read and hx-head has already merged the head, the swap has not happened yet. Can be cancelled.
  • htmx:response:error: the status is 400 or higher. The swap continues anyway.
  • htmx:error: a network failure or an exception during processing.
  • htmx:finally:request: always last, even after an error or cancellation. Cleanup belongs here.

Content swap:

  • htmx:before:swap: fires on the element that sent the request. The address is already new, the content still old. detail.tasks holds the prepared swaps. Can be cancelled.
  • htmx:before:viewTransition: only with a transition turned on. detail.task lets you add work that makes it into the new snapshot.
  • htmx:before:settle, htmx:after:settle: fire on the swap target, the new content is in the DOM. Useful for setting focus.
  • htmx:after:process: htmx has processed the attributes in the new content. htmx.onLoad() builds on this event and also runs on the first load.
  • htmx:after:viewTransition: the transition has finished.
  • htmx:after:swap: fires on the element that sent the request. With a transition it comes only after the transition ends, so later than htmx:after:settle.
  • htmx:finally:swap: the end of the swap, even after an error.
  • htmx:before:cleanup, htmx:after:cleanup: fire on an element htmx is removing from the DOM.

History (fired on document):

  • htmx:before:history:update: the address is about to be written. detail.history.type lets you change push to replace. Can be cancelled.
  • htmx:after:history:push, htmx:after:history:replace, htmx:after:history:update: the address has been written. This comes before the content is swapped.
  • htmx:before:history:restore: a step back or forward, detail.path holds the target address. Cancelling means the page is not downloaded again.

Merging the head from the hx-head extension (fired on <body>):

  • htmx:head:before:merge: cancelling means the head from the response is not used.
  • htmx:head:before:add, htmx:head:before:remove: apply to a single tag, cancelling leaves it alone.
  • htmx:head:after:merge: the head has been merged.

Tags Backend, PHP, JavaScript

Share LinkedIn Facebook

Need more than an article?

A few sentences about what you are dealing with are enough.