htmx Fundamentals

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against htmx 2.0.11 (the latest tag on npm, published on 2026-09-22; htmx 4.0.0 exists only on the next tag, see htmx 4 status), Thymeleaf 3.1.5.RELEASE (2026-04-21), Spring Boot 4.1.1 (Spring Framework 7.0.9) and Java 21, checked on 2026-10-08. Every example was compiled and run in a scratch Bookshelf application (Maven wrapper, JDK 26 with --release 21, MockMvc tests, curl against the running jar, and a Chromium run driven by Playwright 1.62 for the browser behaviour); the HTML, headers, status codes and DOM results shown are the real output of those runs. htmx is a JavaScript library that runs in the browser, so everything about the attributes, the triggers, the swaps and the HX-* headers applies to Thymeleaf standalone, with Spring MVC and with Spring WebFlux alike: the template side is the Standard Dialect and the server side is whatever produces the HTML. The server code on this page is Spring MVC (with Spring Security, so the CSRF parts are Spring MVC specific); a WebFlux controller returns the same fragments, see Reactive Rendering with WebFlux. The library htmx-spring-boot (version 5.2.0) that wraps the headers in Java is the subject of htmx with Spring Boot; this page uses plain Spring MVC and Thymeleaf only, so you see what is on the wire. Recipes built from these pieces (active search, infinite scroll, bulk actions) are on htmx Patterns. The running scenario is the Bookshelf introduced on the Getting Started page.

Hypermedia-driven applications

A classic web page works like this: a link or a form makes the browser request a URL, the server answers with a complete HTML document, and the browser replaces the current page. htmx keeps that model and removes one restriction: any element can issue the request, on any event, with any HTTP method, and the response replaces any element instead of the whole window. The server still answers with HTML, so the application stays a hypermedia application: the markup it returns contains the next set of available actions (links, forms, buttons with hx-* attributes), which is the idea of Hypertext As The Engine Of Application State (HATEOAS). There is no JSON contract to keep in sync with a front-end model, and no client-side templating. This style is called HTML over the wire, and it fits Thymeleaf very well, because a Thymeleaf template can already render a fragment on its own (see Fragments and Fragment Rendering & AJAX).

The whole exchange of one htmx request, from the keystroke to the swapped DOM, looks like this (this is the active-search input of the demo application of this page):

sequenceDiagram participant U as User participant H as htmx in the browser participant S as Spring MVC and Thymeleaf U->>H: types in the search input H->>H: hx-trigger input changed delay 300ms, one request after the pause H->>S: GET /shelf?q=em with HX-Request true, HX-Target results, HX-Trigger-Name q S->>S: controller sees HX-Request and returns the results fragment S-->>H: 200 text/html, only the table H->>H: swap into the results div (innerHTML), add htmx-swapping then htmx-settling H->>U: new table is visible, URL pushed as /shelf?q=em H->>H: events htmx:afterSwap and htmx:afterSettle

htmx has a design rule that matters for Thymeleaf applications: it is progressive enhancement friendly. The search input of the demo sits inside a normal GET form with a submit button, so a browser without JavaScript still searches (the whole page is reloaded). hx-boost goes further and turns ordinary links and forms into AJAX requests with no change to the markup, and falls back to normal navigation when JavaScript is off. Other patterns need more work to degrade gracefully, and some cannot degrade at all; that is a trade-off you make per feature. Two server-side consequences follow, and both appear again in the pitfalls table:

  • The same URL often has to answer a full page (a normal navigation, a copied link, the history restore) and a fragment (an htmx request). The controller decides on the HX-Request request header, and sends Vary: HX-Request so that caches key on it.

  • A response is HTML for the swap, not for the whole window: it is never a redirect page, and a state change is answered with the changed fragment instead of the Post/Redirect/Get dance.

Installing htmx

htmx has no dependencies and needs no build step. Four ways of getting htmx.min.js into a Thymeleaf application, from quickest to most controlled:

Way How

CDN (jsDelivr)

One script tag with a pinned version and a Subresource Integrity (SRI) hash. Quick, but the browser depends on a third party at run time; the htmx docs themselves say to consider not using a CDN in production.

Copy the file

Download htmx.min.js from jsDelivr (or the npm package) into src/main/resources/static/js/ and reference it with th:src="@{/js/htmx.min.js}".

Webjar

The Maven artifact org.webjars.npm:htmx.org wraps the npm package; Spring Boot serves it from /webjars/htmx.org/<version>/dist/htmx.min.js with no extra configuration. This is what the demo application uses.

npm / bundler

npm install htmx.org@2.0.11, then bundle node_modules/htmx.org/dist/htmx.js (or the .min.js) with your own scripts, or import 'htmx.org' in the entry point of a bundler. Use it when a front-end build is already present (for example for Tailwind CSS).

The CDN snippet with SRI. The hash below is the SHA-384 of the exact file at that exact version: it was computed on 2026-10-08 with curl ... | openssl dgst -sha384 -binary | openssl base64 -A and matches the value printed on the htmx documentation. A hash is only valid for the pinned version, so update both together; with @2 or @latest the SRI check would fail on the next release, and without a pinned URL there is nothing to verify.

htmx from the jsDelivr CDN with a pinned version and SRI
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"
        integrity="sha384-2OatzQy1H+Zd/IIrjr1TcuDGqLXeHhbooAyJY1KdQMKnr4LZ22k31GBLdYKHmVjg"
        crossorigin="anonymous"></script>

The webjar variant used by the demo (Maven; the Spring Boot 4.1.1 BOM does not manage org.webjars.npm:htmx.org, so give the version, which is the htmx version):

The htmx webjar in pom.xml
<dependency>
    <groupId>org.webjars.npm</groupId>
    <artifactId>htmx.org</artifactId>
    <version>2.0.11</version>
</dependency>

and the reference from a template, with the versioned path (the context path and any resource versioning are applied by the @{...} link expression; see Link URLs):

Loading htmx from the webjar
<script th:src="@{/webjars/htmx.org/2.0.11/dist/htmx.min.js}" defer></script>

The script is deferred, so other scripts that call htmx (the demo’s shelf.js) must be deferred too and listed after it. The BOM does manage webjars-locator-lite, which can resolve version-less webjar paths; that route was not exercised here. Spring Security must let the unauthenticated login page load the script and styles, so the demo’s filter chain permits /webjars/**, /js/** and /css/**.

Source: WebJars

Extensions (htmx 2)

Since htmx 2 no extension is part of the core file: every extension is a separate small file and a separate npm package, and an extension must be loaded after htmx and then switched on with hx-ext on the element (usually body) it should apply to. The core extensions listed on htmx.org/extensions, with the npm package name that was current on 2026-10-08 (checked in the npm registry):

Extension (hx-ext value) npm package Latest on npm Purpose

sse

htmx-ext-sse

2.2.4

Server-Sent Events from HTML (sse-connect, sse-swap); see Server-Sent Events & WebSockets

ws

htmx-ext-ws

2.0.4

WebSockets from HTML (ws-connect, ws-send)

response-targets

htmx-ext-response-targets

2.0.4

Different targets per HTTP status code (hx-target-404)

head-support

htmx-ext-head-support

2.0.5

Merges head content (styles, scripts) of a response

preload

htmx-ext-preload

2.1.2

Fetches a fragment before the user asks for it

htmx-1-compat

htmx-ext-htmx-1-compat

2.0.2

Restores most of the htmx 1 defaults on htmx 2

idiomorph

idiomorph (not htmx-ext-idiomorph)

not checked

A morph swap strategy based on the Idiomorph library

An extension is loaded after the core file and enabled with hx-ext. This is the example of the htmx documentation for the response-targets extension; the SRI hash was computed from the file served at that URL on 2026-10-08 and equals the one in the documentation:

Loading an extension (response-targets) and enabling it
<head>
    <script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"
            integrity="sha384-2OatzQy1H+Zd/IIrjr1TcuDGqLXeHhbooAyJY1KdQMKnr4LZ22k31GBLdYKHmVjg"
            crossorigin="anonymous"></script>
    <script src="https://cdn.jsdelivr.net/npm/htmx-ext-response-targets@2.0.4"
            integrity="sha384-T41oglUPvXLGBVyRdZsVRxNWnOOqCynaPubjUVjxhsjFTKrFJGEMm3/0KGmNQ+Pg"
            crossorigin="anonymous"></script>
</head>
<body hx-ext="response-targets">
    <button hx-post="/register" hx-target="#response-div" hx-target-404="#not-found">Register!</button>
    <div id="response-div"></div>
    <div id="not-found"></div>
</body>

If an extension seems to do nothing, the usual cause is a missing hx-ext on an ancestor, or a script order in which the extension loaded before htmx. Community extensions (class-tools, json-enc, debug and so on) are listed on the same extensions page and may have their own installation instructions.

The verb attributes with th:hx-*

Five attributes issue a request, each with the URL as its value: hx-get, hx-post, hx-put, hx-patch and hx-delete. The element makes the request when its trigger fires (see Triggers) and, by default, puts the response inside itself.

Attribute Meaning

hx-get

GET the URL; the parameters of the element (and of its form) go in the query string

hx-post

POST; a form-encoded body

hx-put

PUT; a form-encoded body (Spring MVC reads it as request parameters through FormContentFilter, which Boot enables)

hx-patch

PATCH; same body encoding as PUT

hx-delete

DELETE; in htmx 2 the parameters go in the URL, not the body (htmx.config.methodsThatUseUrlParams defaults to ["get", "delete"])

In a Thymeleaf template the URL is a link expression, so the context path, the path variables and the URL encoding are handled by Thymeleaf. The attribute is written as th:hx-get, because the Standard Dialect sets any attribute with the th: prefix by evaluating its value as a Standard Expression (the default attribute processor); th:hx-get="@{/shelf}" renders as hx-get="/shelf". The same works for every hx-* attribute, and a conditional expression that yields null removes the attribute, which this page uses for hx-swap-oob and for the polling of a finished job. If you also write a static hx-get next to the th:hx-get, the static value is what a browser shows when it opens the template file without Thymeleaf, which keeps the template natural.

th:hx-delete with a link expression
<button type="button"
        th:hx-delete="@{/shelf/books/{id}(id=${b.id})}"
        hx-confirm="Remove this book from the shelf?"
        hx-target="closest tr" hx-swap="outerHTML swap:300ms">Remove</button>

Rendered (book 1):

The rendered attributes
<button type="button"
        hx-delete="/shelf/books/1"
        hx-confirm="Remove this book from the shelf?"
        hx-target="closest tr" hx-swap="outerHTML swap:300ms">Remove</button>

The hx-* attribute values that are fixed (hx-target, hx-swap, hx-confirm above) need no th: prefix. Use th:hx-* only for values computed on the server: URLs, ids, flags. Never concatenate user-provided text into an attribute that htmx interprets as code or JSON, see Security. The dialect that adds shorter attributes (hx:get, with automatic CSRF) belongs to the library described on htmx with Spring Boot; everything here works without it.

htmx also accepts the data- prefix for every attribute (data-hx-post), for editors or validators that reject unknown attributes. Attribute names are case-insensitive in HTML, so hx-on handlers cannot use camel-case event names; see Events.

The demo application

All examples below come from one small Spring MVC application, the Bookshelf shelf page at /shelf. It keeps three books in memory (Dune, Emma, Hyperion) in a Shelf bean, and has a controller (ShelfController) and two template files: shelf/index.html (the page) and shelf/fragments.html (the fragments that an htmx request returns). Spring Security is on with its default CSRF protection; the filter chain permits the static resources and additionally enables HTTP Basic, only so that curl can authenticate without a login form.

The head of every page is a fragment. It loads htmx and the demo script, publishes the CSRF token in two meta tags (see CSRF), and puts the htmx configuration in the htmx-config meta tag (see Configuration):

The head fragment (templates/shelf/head.html)
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head th:fragment="head(title)">
  <meta charset="UTF-8">
  <title th:text="${title}">Bookshelf</title>
  <!-- the Spring Security CSRF token for scripts: read by /js/shelf.js on every htmx request -->
  <meta name="_csrf" th:content="${_csrf.token}">
  <meta name="_csrf_header" th:content="${_csrf.headerName}">
  <!-- htmx configuration: never use the history request header, swap 422 responses -->
  <meta name="htmx-config" content='{"historyRestoreAsHxRequest": false, "responseHandling": [{"code": "204", "swap": false}, {"code": "[23]..", "swap": true}, {"code": "422", "swap": true}, {"code": "[45]..", "swap": false, "error": true}, {"code": "...", "swap": false}]}'>
  <link rel="stylesheet" th:href="@{/css/shelf.css}">
  <script th:src="@{/webjars/htmx.org/2.0.11/dist/htmx.min.js}" defer></script>
  <script th:src="@{/js/shelf.js}" defer></script>
</head>
</html>

The controller answers the same URL with the full page or with the fragment. A boosted request also carries HX-Request: true, but it swaps the whole body, so it must get the full page; the controller therefore looks at HX-Boosted as well. The view names are constants chosen by the controller, never built from the request:

The same URL returns the page or the fragment (ShelfController)
    @GetMapping
    String index(@RequestParam(defaultValue = "") String q,
                 @RequestParam(defaultValue = "ALL") Filter filter,
                 @RequestHeader(name = "HX-Request", required = false) String hxRequest,
                 @RequestHeader(name = "HX-Boosted", required = false) String boosted,
                 Model model, HttpServletResponse response) {
        response.addHeader(HttpHeaders.VARY, "HX-Request");
        response.addHeader(HttpHeaders.VARY, "HX-Boosted");
        fill(model, q, filter, new NewBook());
        // a boosted request swaps the whole <body>, so it needs the full page
        boolean fragmentOnly = "true".equals(hxRequest) && boosted == null;
        return fragmentOnly ? "shelf/fragments :: results" : "shelf/index";
    }

    private void fill(Model model, String q, Filter filter, NewBook form) {
        model.addAttribute("q", q);
        model.addAttribute("filter", filter);
        model.addAttribute("books", shelf.search(q, filter));
        model.addAttribute("total", shelf.count());
        model.addAttribute("newBook", form);
    }

The page contains the search field and a div that holds the results table. The th:insert pulls in the same results fragment the controller returns for an htmx request, so there is a single definition of the table:

The search form and the results container (shelf/index.html)
  <!-- active search: works as a plain GET form without JavaScript -->
  <form th:action="@{/shelf}" method="get" role="search">
    <input type="search" name="q" th:value="${q}" placeholder="Search"
           th:hx-get="@{/shelf}" hx-trigger="input changed delay:300ms, search"
           hx-target="#results" hx-push-url="true" hx-indicator="#spinner" hx-sync="this:replace">
    <select name="filter" th:hx-get="@{/shelf}" hx-trigger="change" hx-target="#results"
            hx-include="[name='q']" hx-push-url="true">
      <option th:each="f : ${T(com.example.bookshelf.shelf.Filter).values()}" th:value="${f}" th:text="${f}"
              th:selected="${f == filter}">ALL</option>
    </select>
    <button type="submit">Search</button>
    <span id="spinner" class="htmx-indicator">Searching...</span>
  </form>

  <div id="results" th:insert="~{shelf/fragments :: results}"></div>

Calling the URL with and without the header (the cookie jar keeps the session, SHELF_USER and SHELF_PASSWORD stand for the credentials of the scratch application, which are placeholders in its application.properties):

Page versus fragment with curl
$ curl -s -u "$SHELF_USER:$SHELF_PASSWORD" -c jar.txt -D - -o page.html http://localhost:8080/shelf | grep -iE "^(HTTP|Vary|Content-Type)"
HTTP/1.1 200
Vary: HX-Request
Vary: HX-Boosted
Content-Type: text/html;charset=UTF-8
$ curl -s -u "$SHELF_USER:$SHELF_PASSWORD" -b jar.txt -H "HX-Request: true" "http://localhost:8080/shelf?q=em"
<table>
  <caption>1 of 3 books</caption>
  <thead><tr><th>Title</th><th>Author</th><th>Status</th><th colspan="2">Actions</th></tr></thead>
  <tbody id="rows">

      <tr id="book-2">
  <td>Emma</td>
  <td>Jane Austen</td>
  <td>
    <!-- hx-vals is static JSON; hx-params drops the 'tracking' value again before the request is sent -->
    <button type="button"
            hx-put="/shelf/books/2/read"
            hx-target="closest tr" hx-swap="outerHTML"
            hx-vals='{"stars": 5, "tracking": "x"}' hx-params="not tracking">Unread</button>
  </td>
  <td>
    <button type="button" hx-post="/shelf/books/2/pin" hx-swap="none">Pin</button>
  </td>
  <td>
    <button type="button"
            hx-delete="/shelf/books/2"
            hx-confirm="Remove this book from the shelf?"
            hx-target="closest tr" hx-swap="outerHTML swap:300ms">Remove</button>
  </td>
</tr>


  </tbody>
</table>

Triggers

A request starts when the element’s trigger fires. Without hx-trigger the natural event of the element is used: change for input, textarea and select, submit for a form, and click for everything else. hx-trigger takes a comma-separated list of triggers; each one has its own modifiers. It is not inherited.

Modifier Effect

once

The trigger fires only once

changed

Only fires when the element’s value has changed since the last request (change is the event, changed the modifier)

delay:<time>

Waits, and every new event resets the countdown (a debounce)

throttle:<time>

Ignores further events for the interval

from:<selector>

Listens on another element, for example from:body for hot keys or for events sent by an HX-Trigger response header. Accepts document, window, closest <selector>, find <selector>, next, previous. The selector is evaluated once, not when the page changes

target:<selector>

Only events whose target matches the selector; useful with from:body for elements that are added later

consume

The event does not trigger other htmx requests on ancestors

queue:<option>

What happens to events while a request is in flight: first, last (the default), all or none

A filter is a JavaScript expression in square brackets after the event name; the symbols are resolved against the event first (ctrlKey, key), then the global scope, and this is the element. Filters need eval, so they stop working when htmx.config.allowEval is false. Special events are load (fires once when the element is loaded), revealed (once, when the element scrolls into the viewport), and intersect (once, when it first intersects the viewport; options root:<selector> and threshold:<0.0 to 1.0>). Inside an element with overflow scrolling, use intersect once instead of revealed. Polling is written every <time>, with an optional filter after it (every 1s [!document.hidden] polls only while the tab is visible). A response with HTTP status 286 stops the polling; the response is still swapped. A custom event, for example one sent by the server, is just another trigger name.

The demo has one element per kind of trigger. Each one calls /shelf/echo, a small endpoint that reflects the htmx request headers it received (the full list is in Request and response headers):

One element per kind of trigger (shelf/index.html)
  <section id="gallery">
    <h2>Triggers</h2>
    <button id="t-click" type="button" th:hx-get="@{/shelf/echo}" hx-target="next .out">click</button>
    <output class="out"></output>
    <p id="t-once" th:hx-get="@{/shelf/tip}" hx-trigger="mouseenter once" hx-swap="innerHTML">Hover once for a tip</p>
    <input id="t-delay" name="word" type="text" th:hx-get="@{/shelf/echo}" hx-trigger="input changed delay:400ms" hx-target="next .out">
    <output class="out"></output>
    <button id="t-ctrl" type="button" th:hx-get="@{/shelf/echo}" hx-trigger="click[shiftKey]" hx-target="next .out">shift-click</button>
    <output class="out"></output>
    <input id="t-throttle" name="word" type="text" th:hx-get="@{/shelf/echo}" hx-trigger="keyup throttle:1s" hx-target="next .out">
    <output class="out"></output>
    <div id="t-key" th:hx-get="@{/shelf/echo}" hx-trigger="keyup[shiftKey&&key=='S'] from:body consume" hx-target="this">Press Shift+S</div>
    <button id="t-prompt" type="button" th:hx-post="@{/shelf/echo}" hx-prompt="Your name?" hx-target="next .out">prompt</button>
    <output class="out"></output>
    <div id="t-poll" th:hx-get="@{/shelf/ticker}" hx-trigger="every 500ms">waiting</div>
    <button id="t-import" type="button" th:hx-post="@{/shelf/import}" hx-target="#import-slot" hx-disabled-elt="this">Start import</button>
    <div id="import-slot"></div>
  </section>

The echo endpoint, with the fragment it renders. HX-Trigger is the id of the element that triggered the request, HX-Target the id of the target, HX-Trigger-Name the name of the element, and each missing header is null:

The echo endpoint
    /** Reflects the htmx request headers back, so every trigger and target can be observed. */
    @RequestMapping(path = "/echo", method = { RequestMethod.GET, RequestMethod.POST })
    String echo(HttpServletRequest request, Model model) {
        Map<String, String> seen = new LinkedHashMap<>();
        for (String name : new String[] { "HX-Request", "HX-Boosted", "HX-Target", "HX-Trigger", "HX-Trigger-Name",
                "HX-Current-URL", "HX-Prompt", "HX-History-Restore-Request" }) {
            seen.put(name, request.getHeader(name));
        }
        model.addAttribute("seen", seen);
        model.addAttribute("params", request.getParameterMap().keySet());
        return "shelf/fragments :: echo";
    }
The echo fragment
<ul th:fragment="echo" class="echo">
  <li th:each="e : ${seen}" th:text="|${e.key}: ${e.value}|">HX-Request: true</li>
  <li th:text="|params: ${params}|">params: []</li>
</ul>
The request headers as the server sees them
$ curl -s -u "$SHELF_USER:$SHELF_PASSWORD" -b jar.txt -H 'HX-Request: true' -H 'HX-Target: results' \
    -H 'HX-Trigger: go' -H 'HX-Trigger-Name: q' -H 'HX-Current-URL: http://localhost:8080/shelf' http://localhost:8080/shelf/echo
<ul class="echo">
  <li>HX-Request: true</li>
  <li>HX-Boosted: null</li>
  <li>HX-Target: results</li>
  <li>HX-Trigger: go</li>
  <li>HX-Trigger-Name: q</li>
  <li>HX-Current-URL: http://localhost:8080/shelf</li>
  <li>HX-Prompt: null</li>
  <li>HX-History-Restore-Request: null</li>
  <li>params: []</li>
</ul>

What Chromium (driven by Playwright) did with these triggers:

Trigger Observed

click (default)

One GET /shelf/echo, with HX-Trigger: t-click

mouseenter once

The pointer entered the element twice, one request was made

input changed delay:400ms

Three keystrokes, 80 ms apart: one request, GET /shelf/echo?word=abc, with HX-Trigger-Name: word

click[shiftKey]

A plain click made no request; a click with Shift held made one

keyup[shiftKey&&key=='S'] from:body consume

Pressing Shift+S anywhere in the page made one request

keyup throttle:1s

Eight keystrokes in 0.8 s: one request, for the first key (word=a); the rest were discarded

hx-prompt on a POST

The prompt answer arrived as HX-Prompt: Ada

every 500ms, the server returns 286 on the third response

Exactly three requests, then polling stopped; the 286 response (<p>Update 3</p>) was swapped

load

The statistics panel was filled on page load (0 read of 3) with a GET /shelf/stats

revealed, intersect once threshold:0.5

Nothing happened until the page was scrolled to the bottom; then the recommendations replaced the placeholder and the statistics were loaded

The every element needs a stop condition on the server. ShelfController counts the requests and sets the status itself:

Stopping a poll with HTTP 286 (ShelfController)
    @GetMapping("/ticker")
    String ticker(Model model, HttpServletResponse response) {
        int n = shelf.tickerCount.incrementAndGet();
        model.addAttribute("n", n);
        if (n >= 3) {
            response.setStatus(286);   // htmx stops polling on 286 (the response is still swapped)
        }
        return "shelf/fragments :: ticker";
    }
Polling until the third response
$ for i in 1 2 3; do curl -s -u "$SHELF_USER:$SHELF_PASSWORD" -b jar.txt -w " -> %{http_code}\n" http://localhost:8080/shelf/ticker; done
<p>Update 1</p> -> 200
<p>Update 2</p> -> 200
<p>Update 3</p> -> 286

Load polling is the alternative that needs no special status: an element asks once (load delay:300ms) and replaces itself with the response; when the job is finished the server returns a version of the element without the trigger, and polling ends by itself. The th:hx-get and th:hx-trigger below yield null once the progress reaches 100, so both attributes disappear from the rendered element:

Load polling (shelf/fragments.html)
<!-- load polling: while running, the fragment replaces itself after 300 ms; when done it stops asking -->
<div th:fragment="importStatus" id="import-status"
     th:hx-get="${progress < 100} ? @{/shelf/import/status}"
     th:hx-trigger="${progress < 100} ? 'load delay:300ms'"
     hx-swap="outerHTML">
  <progress max="100" th:value="${progress}" th:text="|${progress}%|">0%</progress>
  <span th:if="${progress == 100}">Import finished</span>
</div>

The import button (th:hx-post="@{/shelf/import}" with hx-target="#import-slot" and hx-disabled-elt="this") starts it. In Chromium the progress element went through 0%, 25%, 50%, 75% and 100% Import finished, and the button was disabled for the duration of the request and enabled again afterwards.

Targets

By default the response is swapped into the element that made the request. hx-target names another element and is inherited, so it can be placed on a form or a container. Its value is a CSS selector, or one of the extended forms:

Value Target

this

The element that carries the attribute

closest <selector>

The nearest ancestor (or the element itself) that matches, for example closest tr

find <selector>

The first descendant that matches; it searches inside the element, not around it

next and next <selector>

The next sibling, or the first match found scanning forward in the document

previous and previous <selector>

The previous sibling, or the first match scanning backwards

<selector/>

The CSS selector wrapped in < and />, mimicking the query literal of hyperscript

body

An ordinary selector; body is also the default target of a boosted element

Most attributes that take a selector accept the same extended syntax. Relative targets avoid peppering the markup with ids. The demo has one button per form; after clicking each of them, its target contained the tip text. One mistake worth noting, because it is easy to make and the error is quiet: find looks inside the element. A first version of the demo had the find span button next to its span, htmx logged htmx:targetError, find span to the console, and nothing was swapped. The hx-target has to be on the container of the span:

One button per target selector (shelf/index.html)
  <section id="targets">
    <h2>Targets</h2>
    <ul>
      <li id="tg-row"><span class="label">row</span> <button id="tg-closest" type="button" th:hx-get="@{/shelf/tip}" hx-target="closest li">closest li</button></li>
      <li><button id="tg-this" type="button" th:hx-get="@{/shelf/tip}" hx-target="this">this</button></li>
      <li><div id="tg-find" th:hx-get="@{/shelf/tip}" hx-target="find span"><button type="button">find span</button> <span>old</span></div></li>
      <li><h3>heading</h3><button id="tg-prev" type="button" th:hx-get="@{/shelf/tip}" hx-target="previous h3">previous h3</button></li>
      <li><button id="tg-next" type="button" th:hx-get="@{/shelf/tip}" hx-target="next .nextbox">next .nextbox</button><div class="nextbox">old</div></li>
      <li><button id="tg-lit" type="button" th:hx-get="@{/shelf/tip}" hx-target="&lt;#tg-lit-box/&gt;">query literal</button></li>
    </ul>
    <div id="tg-lit-box">old</div>
  </section>

The server sees the id of the target in the HX-Target header (see below). That is also useful for choosing a fragment: a controller that serves two different targets from one URL can return different constant fragments, but it must map the id to a fixed view name instead of using it in a template name or selector.

Swaps

hx-swap says how the response is put into the target. The default is innerHTML (the value of htmx.config.defaultSwapStyle); the attribute is inherited. The demo starts every swap from the same list:

The element every swap strategy starts from
<ul id="swap-list"><li id="swap-target" class="piece">target</li></ul>

and every button requests /shelf/swap/piece, which returns <li class="piece">new</li>, with hx-target="#swap-target" and a different hx-swap. The resulting ul after each click in Chromium:

hx-swap The list afterwards

innerHTML (default)

<li id="swap-target" class="piece"><li class="piece">new</li></li>

outerHTML

<li class="piece">new</li>

textContent

<li id="swap-target" class="piece">&lt;li class="piece"&gt;new&lt;/li&gt;</li> (the response is not parsed as HTML)

beforebegin

<li class="piece">new</li><li id="swap-target" class="piece">target</li>

afterbegin

<li id="swap-target" class="piece"><li class="piece">new</li>target</li>

beforeend

<li id="swap-target" class="piece">target<li class="piece">new</li></li>

afterend

<li id="swap-target" class="piece">target</li><li class="piece">new</li>

delete

(empty): the target is removed whatever the response

none

unchanged: the response is not inserted (out-of-band swaps and response headers are still processed)

(The li inside li is the browser DOM after the swap, shown literally; it is invalid HTML but proves what each strategy does.) The th:hx-swap on the demo buttons comes from a loop over the nine names, which also shows that a th:hx-* value may be any expression:

One button per swap strategy (shelf/index.html)
  <section id="swaps">
    <h2>Swaps</h2>
    <ul id="swap-list"><li id="swap-target" class="piece">target</li></ul>
    <th:block th:each="s : ${#strings.arraySplit('innerHTML,outerHTML,textContent,beforebegin,afterbegin,beforeend,afterend,delete,none', ',')}">
      <button type="button" th:id="|sw-${s}|" th:text="${s}" th:hx-get="@{/shelf/swap/piece}" hx-target="#swap-target" th:hx-swap="${s}">swap</button>
    </th:block>
    <button id="sw-transition" type="button" th:hx-get="@{/shelf/swap/piece}" hx-target="#swap-target" hx-swap="innerHTML transition:true">transition</button>
    <button id="sw-settle" type="button" th:hx-get="@{/shelf/swap/piece}" hx-target="#swap-target" hx-swap="innerHTML settle:1s">settle:1s</button>
    <div id="scrollbox" style="height: 60px; overflow: scroll"><p th:each="i : ${#numbers.sequence(1, 10)}" th:text="|line ${i}|">line</p></div>
    <button id="sw-scroll" type="button" th:hx-get="@{/shelf/tip}" hx-target="#scrollbox" hx-swap="beforeend scroll:bottom">scroll:bottom</button>
    <button id="sw-title" type="button" th:hx-get="@{/shelf/swap/titled}" hx-target="#swap-target">title swap</button>

Morphing swaps merge the new content into the existing DOM instead of replacing it, which preserves focus and state, at the cost of more CPU; they come from the idiomorph extension (or morphdom-swap and alpine-morph, which are community extensions).

Swap modifiers

Modifiers follow the strategy, separated by spaces, and the options are colon-separated: hx-swap="outerHTML swap:300ms ignoreTitle:true".

Modifier Effect Observed in the demo

swap:<time>

Delay between receiving the response and the swap (default 0 ms); the target has the htmx-swapping class meanwhile, for CSS fade-outs

The deleted row had htmx-swapping during the 300 ms, then left the DOM

settle:<time>

Delay between swap and settle (default 20 ms); new content has htmx-added and the target has htmx-settling

With settle:1s both classes were present 400 ms after the swap and gone 1.3 s after it

ignoreTitle:true

Does not take a <title> found in the response

The title of the page stayed Bookshelf; without the modifier a response containing <title>Changed title</title> changed it

scroll:top or scroll:bottom

Scrolls the target (or scroll:<selector>:top, scroll:window:top)

beforeend scroll:bottom on a 60 px box moved its scrollTop away from 0

show:top or show:bottom

Scrolls the target (or show:<selector>:top, show:window:top) into view; show:none turns off the default of boosted links

Documented, not run

focus-scroll:true

Scrolls to the focused input after the swap (htmx otherwise preserves focus for inputs with an id but does not scroll)

Documented, not run

transition:true

Wraps the swap in the View Transitions API

htmx:beforeTransition fired; document.startViewTransition exists in Chromium 151

The htmx-added, htmx-settling, htmx-swapping and htmx-request classes, plus the swap and settle delays, are all that is needed for CSS transitions. Keep an element’s id stable across the swap and htmx copies the old attributes onto the new element before inserting it and applies the new values after the settle delay, which makes a CSS transition run without JavaScript. The delete animation of the demo is exactly this (tr.htmx-swapping { opacity: 0; transition: opacity 300ms ease-out; }).

View transitions

transition:true in hx-swap (or htmx.config.globalViewTransitions = true for every swap) uses the browser’s View Transitions API; on a browser without it, htmx falls back to a normal swap. The htmx:beforeTransition event can be cancelled to skip the animation for one swap. The transition itself is configured with CSS (::view-transition-* pseudo-elements), not with htmx.

A swap inside a view transition
<button id="sw-transition" type="button" th:hx-get="@{/shelf/swap/piece}" hx-target="#swap-target" hx-swap="innerHTML transition:true">transition</button>

Controlling the request

Besides the verb, the trigger, the target and the swap, a handful of attributes shape what is sent and what happens around it. Most are inherited, so one attribute on a container applies to every htmx element inside it; set a child to unset (or use hx-disinherit) to opt out, and htmx.config.disableInheritance plus hx-inherit turn the model around. hx-trigger and hx-swap-oob are not inherited.

Attribute Purpose

hx-select

A CSS selector that picks part of the response to swap (the response can be a whole page)

hx-select-oob

Picks further parts of the response by id for out-of-band swaps; #alert:afterbegin adds a swap strategy

hx-include

Adds the values of other elements to the request: a selector, this, closest, find, next, previous, and inherit to keep the parent’s value. A non-input element includes the inputs inside it; disabled inputs are skipped

hx-vals

Extra parameters as a JSON object; a js: prefix evaluates JavaScript instead (needs allowEval)

hx-params

Filters the parameters: * (all, the default), none, not <list> or a list of names

hx-confirm

A window.confirm dialog before the request; the htmx:confirm event allows an asynchronous replacement

hx-prompt

A window.prompt; the answer is sent as the HX-Prompt header

hx-indicator

Which element gets the htmx-request class during the request (default: the requesting element); closest <selector> and inherit are supported

hx-disabled-elt

Elements that get the disabled attribute during the request: this, closest, find, next, previous or a selector list

hx-sync

Coordinates requests of different elements: drop, abort, replace, queue first, queue last or queue all, for example closest form:abort or this:replace

hx-encoding

multipart/form-data for file uploads (htmx:xhr:progress reports the upload progress)

hx-headers

Extra request headers as JSON; a js: prefix evaluates JavaScript

hx-validate

Forces non-form elements to validate before a request; forms are validated by default using the HTML5 Validation API

hx-preserve

Keeps an element unchanged across swaps (a playing video)

hx-ext

Enables an extension for the element and its descendants

hx-disable

Stops htmx processing the element and everything inside it (a security tool, see Security)

The most used ones in the demo, in the rows and the toolbar of the page. The status button sends stars but not tracking: hx-vals is static JSON, and hx-params="not tracking" drops it again; the filter select borrows the search text with hx-include; the delete button asks first:

Row actions with hx-vals, hx-params, hx-confirm and hx-target (shelf/fragments.html)
<tr th:fragment="row(b)" th:id="|book-${b.id}|">
  <td th:text="${b.title}">Dune</td>
  <td th:text="${b.author}">Frank Herbert</td>
  <td>
    <!-- hx-vals is static JSON; hx-params drops the 'tracking' value again before the request is sent -->
    <button type="button"
            th:hx-put="@{/shelf/books/{id}/read(id=${b.id})}"
            hx-target="closest tr" hx-swap="outerHTML"
            hx-vals='{"stars": 5, "tracking": "x"}' hx-params="not tracking"
            th:text="${b.read} ? |Read (${b.stars}/5)| : 'Unread'">Unread</button>
  </td>
  <td>
    <button type="button" th:hx-post="@{/shelf/books/{id}/pin(id=${b.id})}" hx-swap="none">Pin</button>
  </td>
  <td>
    <button type="button"
            th:hx-delete="@{/shelf/books/{id}(id=${b.id})}"
            hx-confirm="Remove this book from the shelf?"
            hx-target="closest tr" hx-swap="outerHTML swap:300ms">Remove</button>
  </td>
</tr>

What the browser sent (Playwright’s record of the requests; the Cookie and CSRF header are left out):

Interaction Request

Click "Unread" on Emma

PUT /shelf/books/2/read, Content-Type: application/x-www-form-urlencoded, body stars=5, headers HX-Request: true, HX-Target: book-2, HX-Current-URL: http://localhost:8238/shelf?q=

Choose READ in the filter while em is typed in the search box

GET /shelf?filter=READ&q=em

Click "Remove" and cancel the dialog

The dialog text was "Remove this book from the shelf?"; no request was made

Click "Remove" and accept

DELETE /shelf/books/3; the row got htmx-swapping, faded for 300 ms, and left the table

The PUT reached the controller as ordinary request parameters, because Spring Boot’s FormContentFilter parses the body of PUT, PATCH and DELETE forms:

The PUT handler reads form parameters (ShelfController)
    @PutMapping("/books/{id}/read")
    String toggleRead(@PathVariable long id, @RequestParam(defaultValue = "0") int stars, Model model) {
        ShelfBook book = shelf.find(id).orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        if (book.isRead()) { book.markUnread(); } else { book.markRead(Math.max(0, Math.min(5, stars))); }
        model.addAttribute("book", book);
        return "shelf/fragments :: rowOf";
    }

hx-select is for when the server returns more than you want. The button below requests the whole About page and keeps only the element with the id about-body; the rest of the page never reaches the DOM:

hx-select keeps one element of a full page
<button id="sel-about" type="button" th:hx-get="@{/shelf/about}" hx-select="#about-body" hx-target="#about-box">hx-select the about text</button>
<div id="about-box"></div>

In Chromium #about-box afterwards contained exactly <div id="about-body"><p>The Bookshelf keeps your reading list.</p></div>.

A request indicator uses the htmx-request class: the class is put on the requesting element, or on the element named by hx-indicator, and the default style sheet that htmx injects (switch it off with includeIndicatorStyles) fades any htmx-indicator inside it in. The search field of the demo uses hx-indicator="#spinner" with <span id="spinner" class="htmx-indicator">; the opacity transition itself was not measured. hx-disabled-elt="find button" on the add form and hx-disabled-elt="this" on the import button block double submissions.

Out-of-band swaps

A response can carry more than the main fragment. An element with hx-swap-oob is taken out of the response and swapped into the page by id, wherever it is, and everything else goes to the target in the normal way. The value is true (same as outerHTML), any hx-swap value, or a swap value, a colon and a selector (beforeend:#table tbody). With a strategy other than outerHTML the wrapping element is stripped, so give it the right tag for the context. An element that cannot stand alone in HTML (tr, td, thead, tbody, li and others) is put inside a template element; the template is removed afterwards. It is the way to update a counter, a toast or a navigation highlight in the same response as the main change.

The demo adds a book. The response is the new table row for the target (hx-target="#rows" with hx-swap="beforeend") plus the book counter in the page heading. The counter is the same fragment the page itself includes, rendered without the attribute the first time (oob is false) and with it in the response (oob is true); th:hx-swap-oob yields null when oob is false and the attribute is not rendered:

The counter as an out-of-band fragment (shelf/fragments.html)
<!-- the count is the out-of-band part; the attribute is only rendered when 'oob' is true -->
<span th:fragment="count(oob)" id="count" th:hx-swap-oob="${oob} ? 'true'" th:text="${total}">3</span>

<!-- response of a successful POST: the new row for the main target plus the out-of-band counter -->
<th:block th:fragment="added">
  <tr th:replace="~{:: row(${added})}"></tr>
  <span th:replace="~{:: count(true)}"></span>
</th:block>

Controller and the wire (curl; the token comes from the page, see CSRF):

Adding a book: the row plus the counter (ShelfController)
    @PostMapping("/books")
    String add(@Valid @ModelAttribute("newBook") NewBook newBook, BindingResult result,
               @RequestHeader(name = "HX-Request", required = false) String hxRequest,
               Model model, HttpServletResponse response) {
        boolean htmx = "true".equals(hxRequest);
        if (result.hasErrors()) {
            if (!htmx) {
                model.addAttribute("q", "");
                model.addAttribute("filter", Filter.ALL);
                model.addAttribute("books", shelf.search("", Filter.ALL));
                model.addAttribute("total", shelf.count());
                return "shelf/index";
            }
            response.setStatus(HttpStatus.UNPROCESSABLE_ENTITY.value());
            response.setHeader("HX-Retarget", "#add-form");
            response.setHeader("HX-Reswap", "outerHTML");
            return "shelf/fragments :: addForm";
        }
        ShelfBook added = shelf.add(newBook.getTitle().strip(), newBook.getAuthor().strip());
        if (!htmx) {
            return "redirect:/shelf";   // Post/Redirect/Get for clients without htmx
        }
        model.addAttribute("added", added);
        model.addAttribute("total", shelf.count());
        // the payload is a number generated by the server, never user input
        response.setHeader("HX-Trigger", "{\"bookAdded\":{\"id\":" + added.getId() + "}}");
        return "shelf/fragments :: added";
    }
The add response on the wire
$ curl -s -u "$SHELF_USER:$SHELF_PASSWORD" -b jar.txt -D - -X POST -H 'HX-Request: true' -H "X-CSRF-TOKEN: $TOKEN" \
    -d 'title=Solaris&author=Stanislaw+Lem' http://localhost:8080/shelf/books | grep -iE "^HTTP|^HX-|<tr|<span|<td>"
HTTP/1.1 200
HX-Trigger: {"bookAdded":{"id":4}}
  <tr id="book-4">
  <td>Solaris</td>
  <td>Stanislaw Lem</td>
  <td>
  <td>
  <td>
  <span id="count" hx-swap-oob="true">4</span>

In the browser the new row appeared at the end of the table, #count changed from 3 to 4, the hx-on::after-request handler reset the form, and the bookAdded event (see Server-triggered events) wrote "Book #4 added" in the toast.

Notes on out-of-band swaps:

  • By default any element with hx-swap-oob in the response is processed, even one nested inside the main fragment. That is a problem when the same fragment is also a normal part of a larger response (it is pulled out of its place). htmx.config.allowNestedOobSwaps set to false limits out-of-band swaps to elements adjacent to the main content.

  • An id that does not exist in the page raises htmx:oobErrorNoTarget; nothing is swapped.

  • The events htmx:oobBeforeSwap and htmx:oobAfterSwap surround each out-of-band swap.

  • hx-select-oob on the requesting element is the opposite approach: the page, not the response, says which parts of a full-page response to pick.

  • The htmx 2.0.11 documentation also describes hx-partial, a server-sent swap command where the response wraps content in hx-partial elements with an hx-target. It is in the 2.0.11 source and in the reference list, but this page does not use it; the hx-swap-oob form above is the long-standing and portable one.

A debounced PUT and a locale-aware date in the out-of-band part

An out-of-band fragment is also a good place for text that depends on the user’s locale. The reading log has a "pages read today" input that saves itself half a second after the last keystroke (keyup changed delay:500ms, a debounce) with hx-put. The page has nothing to swap into the input, so the request uses hx-swap="none"; the out-of-band elements in the response are still processed. The response names the first day of the user’s week and the new total:

A debounced PUT whose answer is only out-of-band elements
<input type="number" name="pages" min="0"
       hx-put="/patterns/log" hx-trigger="keyup changed delay:500ms" hx-swap="none">
<p>Week of <span id="week-label"></span>: <span id="pages-total">0</span> pages</p>
The fragment (patterns/log.html); the week start is computed in the controller
<th:block th:fragment="summary">
  <p id="week-label" hx-swap-oob="true"
     th:text="${#temporals.format(weekStart, 'EEEE d MMMM yyyy', #locale)}">Monday 5 October 2026</p>
  <p id="pages-total" hx-swap-oob="true" th:text="${pages}">0</p>
</th:block>

The controller takes the request Locale and asks WeekFields.of(locale) for the first day of the week (today.with(WeekFields.of(locale).dayOfWeek(), 1)), because a week starts on Sunday in the United States and on Monday in Spain:

    @PutMapping("/patterns/log")
    String update(@RequestParam int pages, Locale locale, Model model) {
        model.addAttribute("weekStart", LocalDate.now().with(WeekFields.of(locale).dayOfWeek(), 1));
        model.addAttribute("pages", pages);
        return "patterns/log :: summary";
    }

For 12 pages on Thursday 8 October 2026 the response was Sunday 4 October 2026 for Accept-Language: en-US and lunes 5 octubre 2026 for es-ES, each with <p id="pages-total" hx-swap-oob="true">12</p>. The #temporals utility object formats java.time values and takes the locale as its third argument; see Expression Utility Objects.

Several fragments in one response from Spring MVC with FragmentsRendering, and the same idea with the htmx-spring-boot return types, are described on Fragment Rendering & AJAX and htmx with Spring Boot.

Boost, history and the location bar

hx-boost="true" converts the anchors and forms inside an element into AJAX requests: a link becomes a GET whose response replaces the body (the innerHTML swap), the URL is pushed into the browser history, and a form is submitted with its own method and action. Only links to the same domain that are not local anchors are boosted. hx-boost="false" turns it off for a child, and with JavaScript disabled everything is a normal link. The request carries HX-Boosted: true; since it is an AJAX request, keep that in mind for redirects. The demo boosts its navigation:

Boosted navigation
<nav hx-boost="true"><a th:href="@{/shelf}">Shelf</a> <a th:href="@{/shelf/about}">About</a></nav>

In Chromium a click on "About" sent GET /shelf/about with HX-Request: true, HX-Boosted: true and HX-Current-URL; the page was not reloaded (a JavaScript marker survived), the URL changed to /shelf/about and document.title became "About - Bookshelf", because htmx takes the title of the response.

History. hx-push-url="true" pushes the request URL (or the URL given as the attribute value) onto the browser history; hx-replace-url replaces the current entry; false disables a push that would come from an ancestor or from boost. Before the request, htmx saves a snapshot of the body (or of the element with hx-history-elt) in the browser’s web storage, and when the user goes back it swaps the snapshot in again. On a cache miss it requests the URL from the server with HX-History-Restore-Request: true and expects the full page; if htmx.config.refreshOnHistoryMiss is true it reloads the page instead. The documentation says the snapshots live in localStorage; the 2.0.11 source and the Chromium run use sessionStorage under the key htmx-history-cache, so clear that key when testing. hx-history="false" anywhere on a page keeps it out of the cache (for sensitive pages), and htmx.config.historyCacheSize set to 0 disables the cache.

The demo pushes the search URL (hx-push-url="true" on the input and the select; the URL after typing em was /shelf?q=em). The two back-navigation cases, measured in Chromium after a boosted click on "About":

Case What was sent

Back, snapshot in the cache

No request for /shelf was made; the old DOM came back from the snapshot

Back, snapshot removed

GET /shelf with HX-History-Restore-Request: true and without HX-Request, because the page sets historyRestoreAsHxRequest to false

Without that setting a history restore request also carries HX-Request: true, so a controller that returns a fragment for every htmx request would send a fragment where the whole page is needed; the docs recommend always disabling it, and the demo does in the htmx-config meta tag. Vary: HX-Request (the demo adds it) keeps a browser cache from serving a fragment for the full URL; alternatively htmx.config.getCacheBusterParam adds a cache-busting parameter to htmx GET requests.

If you push a URL, you must be able to open that URL directly and get a full page back: someone will paste it into an email, and history restoration needs it.

Request and response headers

htmx communicates with the server through headers, so the controller can tell an htmx request from a normal one, and the server can steer what the client does with the response.

Request header Meaning

HX-Request

Always true for htmx requests (not sent on history restore if historyRestoreAsHxRequest is false)

HX-Boosted

The request came from an element with hx-boost

HX-Target

Carries the id of the element being swapped (sent only when that element has an id)

HX-Trigger

Carries the id of the element that fired the request (only when it has one)

HX-Trigger-Name

Carries the name attribute of the element that fired the request (only when it has one)

HX-Current-URL

The current URL of the browser

HX-Prompt

The answer to an hx-prompt

HX-History-Restore-Request

true for a history restore after a cache miss

Response header Effect

HX-Location

A client-side redirect without a full reload, like following an hx-boost link. A plain path or a JSON object with path and the fields of the htmx.ajax context (target, swap, select, values, headers, push, replace, source, event, handler)

HX-Push-Url

Pushes a URL onto the history

HX-Replace-Url

Rewrites the browser’s address-bar URL without adding a history entry

HX-Redirect

A client-side redirect to a new location (a full page load)

HX-Refresh

true reloads the page

HX-Retarget

A CSS selector that replaces the target of the swap

HX-Reswap

Replaces the swap strategy; any hx-swap value

HX-Reselect

A CSS selector for the part of the response to swap, overriding hx-select

HX-Trigger

Triggers client-side events when the response is received

HX-Trigger-After-Swap

Triggers events after the swap step

HX-Trigger-After-Settle

Triggers events after the settle step

Response headers are not processed on 3xx responses: the browser follows a redirect internally and htmx sees the headers of the final response. Use a 200 for responses whose headers must be honoured, and HX-Redirect or HX-Location instead of a 302 to move an htmx request somewhere else. This is also why Post/Redirect/Get is no longer needed for an htmx form: return the new fragment.

Server-triggered events

HX-Trigger takes an event name (pinned), a comma-separated list (event1, event2) or a JSON object ({"showMessage": "text"} puts the value in evt.detail.value; a nested object puts each property in evt.detail). Add a target property to fire the event on another element ({"showMessage": {"target": "#otherElement"}}). The event is fired on the triggering element and bubbles to the body, so an element that reacts to it needs from:body (hx-trigger="pinned from:body") or a listener on document.body. HX-Trigger fires as soon as the response is received, HX-Trigger-After-Swap after the swap and HX-Trigger-After-Settle after the settle step.

The demo has endpoints that set each response header (/shelf/lab/trigger, /push, /replace, /location, /redirect, /refresh, /retarget and /reselect):

One endpoint per response header (ShelfController)
    enum Lab { TRIGGER, PUSH, REPLACE, LOCATION, REDIRECT, REFRESH, RETARGET, RESELECT }

    @GetMapping("/lab/{name}")
    String lab(@PathVariable String name, HttpServletResponse response) {
        Lab kind;
        try {
            kind = Lab.valueOf(name.toUpperCase(java.util.Locale.ROOT));
        } catch (IllegalArgumentException e) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }
        switch (kind) {
            case TRIGGER -> {
                response.setHeader("HX-Trigger", "{\"toast\":{\"level\":\"info\",\"message\":\"Saved\"}}");
                response.setHeader("HX-Trigger-After-Swap", "afterSwapEvent");
                response.setHeader("HX-Trigger-After-Settle", "afterSettleEvent");
            }
            case PUSH -> response.setHeader("HX-Push-Url", "/shelf/about");
            case REPLACE -> response.setHeader("HX-Replace-Url", "/shelf?filter=READ");
            case LOCATION -> response.setHeader("HX-Location", "{\"path\":\"/shelf/about\",\"target\":\"#lab-out\",\"select\":\"#about-body\"}");
            case REDIRECT -> response.setHeader("HX-Redirect", "/shelf/about");
            case REFRESH -> response.setHeader("HX-Refresh", "true");
            case RETARGET -> {
                response.setHeader("HX-Retarget", "#lab-other");
                response.setHeader("HX-Reswap", "beforeend");
            }
            case RESELECT -> response.setHeader("HX-Reselect", "#keep");
        }
        return "shelf/fragments :: labReply";
    }

and the page listens for the events and records their order:

The page script: CSRF, server events, onLoad and htmx.ajax (static/js/shelf.js)
// CSRF: add the Spring Security token to every htmx request
document.addEventListener('htmx:configRequest', (evt) => {
  const token = document.querySelector('meta[name="_csrf"]').content;
  const header = document.querySelector('meta[name="_csrf_header"]').content;
  evt.detail.headers[header] = token;
});

// server-triggered events (HX-Trigger response header) bubble up to the body
const say = (text) => {
  const toast = document.getElementById('toast');
  if (toast) { toast.textContent = text; }
};
document.body.addEventListener('bookAdded', (evt) => say('Book #' + evt.detail.id + ' added'));
document.body.addEventListener('toast', (evt) => say(evt.detail.level + ': ' + evt.detail.message));
document.body.addEventListener('pinned', () => say('Pinned'));

// order of the response-header events
for (const name of ['toast', 'afterSwapEvent', 'afterSettleEvent', 'htmx:afterSwap', 'htmx:afterSettle', 'htmx:beforeTransition']) {
  document.body.addEventListener(name, () => {
    const log = document.getElementById('event-log');
    if (log) { log.textContent += name + '\n'; }
  });
}

// run code for every piece of content that htmx adds, and once for the initial page
let loaded = 0;
htmx.onLoad((elt) => {
  loaded += 1;
  document.body.dataset.loaded = String(loaded);
});

document.getElementById('js-ajax')?.addEventListener('click', () => {
  htmx.ajax('GET', '/shelf/tip', { target: '#about-box', swap: 'innerHTML' });
});

What each header did in Chromium when the corresponding button was clicked (the buttons all target #lab-out):

Header Result

HX-Trigger + HX-Trigger-After-Swap + HX-Trigger-After-Settle

The event log read toast, htmx:afterSwap, afterSwapEvent, htmx:afterSettle, afterSettleEvent; the JSON detail reached the listener (toast "info: Saved")

HX-Trigger: pinned on a 204 (POST /shelf/books/2/pin)

No DOM change (204 never swaps), the toast said "Pinned"

HX-Trigger: {"bookAdded":{"id":4}}

The listener read evt.detail.id and wrote "Book #4 added"

HX-Push-Url: /shelf/about

The URL became /shelf/about with no navigation

HX-Replace-Url: /shelf?filter=READ

The URL was replaced

HX-Location with path, target and select

The URL became /shelf/about and only #about-body of that page was swapped into #lab-out

HX-Redirect: /shelf/about

A full navigation to /shelf/about

HX-Refresh: true

The page reloaded (a JavaScript marker was lost)

HX-Retarget: #lab-other + HX-Reswap: beforeend

The response was appended to #lab-other instead of #lab-out

HX-Reselect: #keep

Only the p with the id keep of the response was swapped

Keep header payloads small and generated by the server (an id, a level, a fixed message). HTTP header values are not the place for user-provided text, and the demo sends the numeric id and lets the page write the text. Do not build the JSON with string concatenation from request data; the example concatenates only a number the server generated.

Responses, errors and status codes

htmx swaps responses according to their HTTP status. The defaults of htmx.config.responseHandling are: 204 is not swapped and is not an error, 2xx and 3xx are swapped, 4xx and 5xx are not swapped and are errors (htmx:responseError is fired), and anything else is not swapped. A network failure fires htmx:sendError. The array can be replaced; each entry has a code (a regular expression), swap, error, ignoreTitle, select, target and swapOverride. For inline validation, the usual change is to swap 422 responses; the demo does it in the htmx-config meta tag shown above.

The add form of the demo then works like this. An invalid submission answers 422, retargets the response from the table to the form itself, and re-renders the form with the messages:

Invalid input: 422, HX-Retarget and HX-Reswap (ShelfController)
        if (result.hasErrors()) {
            if (!htmx) {
                model.addAttribute("q", "");
                model.addAttribute("filter", Filter.ALL);
                model.addAttribute("books", shelf.search("", Filter.ALL));
                model.addAttribute("total", shelf.count());
                return "shelf/index";
            }
            response.setStatus(HttpStatus.UNPROCESSABLE_ENTITY.value());
            response.setHeader("HX-Retarget", "#add-form");
            response.setHeader("HX-Reswap", "outerHTML");
            return "shelf/fragments :: addForm";
        }
The add form fragment (shelf/fragments.html)
<form th:fragment="addForm" id="add-form" th:object="${newBook}"
      th:action="@{/shelf/books}" method="post"
      th:hx-post="@{/shelf/books}" hx-target="#rows" hx-swap="beforeend"
      hx-disabled-elt="find button"
      hx-on::after-request="if (event.detail.successful) this.reset()">
  <label>Title <input type="text" th:field="*{title}"></label>
  <small class="error" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">error</small>
  <label>Author <input type="text" th:field="*{author}"></label>
  <small class="error" th:if="${#fields.hasErrors('author')}" th:errors="*{author}">error</small>
  <button type="submit">Add</button>
</form>

In Chromium, submitting the empty title showed "must not be blank" next to both fields, the table kept its four rows, and the page still had exactly one #add-form (the outerHTML swap replaced it). Over curl the invalid POST answers (the message text follows the locale of the request):

The 422 response over curl
$ curl -s -u "$SHELF_USER:$SHELF_PASSWORD" -b jar.txt -D - -X POST -H 'HX-Request: true' -H "X-CSRF-TOKEN: $TOKEN" \
    -d 'title=&author=x' http://localhost:8080/shelf/books | grep -iE "^HTTP|^HX-"
HTTP/1.1 422
HX-Retarget: #add-form
HX-Reswap: outerHTML

The same flow without JavaScript is a normal form post: the controller sees no HX-Request header, renders the whole page with the errors (or redirects after a successful save). The delete answer is 200 with an empty body. A 204 No Content does not swap, even with a body, which is right for "do something, change nothing" (the pin button), and wrong for an outerHTML delete that should remove the row.

Delete answers 200 and pin answers 204 (ShelfController)
    @DeleteMapping("/books/{id}")
    ResponseEntity<Void> delete(@PathVariable long id) {
        shelf.remove(id);
        return ResponseEntity.ok().build();   // 200 with an empty body: outerHTML swaps the row away
    }

    @PostMapping("/books/{id}/pin")
    ResponseEntity<Void> pin(@PathVariable long id) {
        // 204 never swaps, but response headers are still processed
        return ResponseEntity.noContent().header("HX-Trigger", "pinned").build();
    }
Both answers over curl
$ curl -s -u "$SHELF_USER:$SHELF_PASSWORD" -b jar.txt -D - -X DELETE -H 'HX-Request: true' -H "X-CSRF-TOKEN: $TOKEN" http://localhost:8080/shelf/books/1 | grep -iE "^HTTP|content-length"
HTTP/1.1 200
Content-Length: 0
$ curl -s -u "$SHELF_USER:$SHELF_PASSWORD" -b jar.txt -D - -X POST -H 'HX-Request: true' -H "X-CSRF-TOKEN: $TOKEN" http://localhost:8080/shelf/books/2/pin | grep -iE "^HTTP|^HX-"
HTTP/1.1 204
HX-Trigger: pinned

For errors you want to handle with code, listen for htmx:responseError (a 4xx or 5xx that is not swapped), htmx:sendError (network) and htmx:timeout, or change the swap decision for one response in a htmx:beforeSwap listener (evt.detail.shouldSwap, evt.detail.isError, evt.detail.target). The response-targets extension does the same declaratively. The page Error Handling & Flash Messages covers the server-side half (error pages, @ControllerAdvice).

Events and the JavaScript API

Every step of a request fires an event on the element, bubbling up the DOM, and each event is fired under two names: camel-case (htmx:afterSwap) and kebab-case (htmx:after-swap), so Alpine.js and the attribute-based hx-on handlers can use them. The ones needed most often:

Event When

htmx:load

New content was added to the DOM (also for the initial page); htmx.onLoad is the shortcut

htmx:configRequest

Before the request is sent: change evt.detail.parameters and evt.detail.headers

htmx:confirm

After a trigger, before the request; can delay or cancel it (evt.detail.issueRequest())

htmx:beforeRequest, htmx:beforeSend, htmx:afterRequest

Around the request (evt.detail.successful)

htmx:beforeSwap, htmx:afterSwap, htmx:afterSettle

Around the swap; beforeSwap can change shouldSwap, target and isError

htmx:oobBeforeSwap, htmx:oobAfterSwap, htmx:oobErrorNoTarget

Out-of-band swaps

htmx:responseError, htmx:sendError, htmx:swapError, htmx:targetError, htmx:timeout

Errors

htmx:validation:validate, htmx:validation:failed, htmx:validation:halted

Client-side validation

htmx:beforeHistorySave, htmx:historyRestore, htmx:pushedIntoHistory, htmx:replacedInHistory

History

htmx:beforeTransition

Before a view transition; cancel with preventDefault()

htmx:abort

Send it to an element to cancel its in-flight request

Handlers: document.body.addEventListener(name, fn), htmx.on(name, fn), or inline with hx-on:<event> attributes. The hx-on attribute names are case-insensitive, so use the kebab-case event name (hx-on:htmx:after-request) or the shorthand hx-on::after-request for events that start with htmx:. In the handler this is the element and event is the event. The add form of the demo resets itself with hx-on::after-request="if (event.detail.successful) this.reset()". Attribute handlers need allowEval. htmx.logAll() logs every event and htmx.logger installs a custom logger; the browser console’s monitorEvents(element) shows what a DOM element fires.

The JavaScript API is the htmx global:

Function Use

htmx.ajax(verb, path, target) or htmx.ajax(verb, path, context)

Issues an htmx-style request from code and returns a Promise. context has source, event, handler, target, swap, values, headers, select, selectOOB, push, replace

htmx.onLoad(callback)

Calls callback(elt) for newly loaded content (a third-party widget initialiser that must not run twice)

htmx.process(elt)

Makes htmx look at content that JavaScript inserted (for example with fetch and innerHTML, or from an Alpine template)

htmx.trigger(elt, name, detail)

Fires an event, for example htmx.trigger('#button', 'htmx:abort')

htmx.find, htmx.findAll, htmx.closest

Query helpers

htmx.on, htmx.off

Event listeners

htmx.swap, htmx.values, htmx.remove, htmx.addClass, htmx.removeClass, htmx.toggleClass, htmx.takeClass

DOM helpers

htmx.config

The configuration object, see Configuration

htmx.defineExtension(name, extension)

Registers an extension

The page script above calls htmx.ajax from a click handler, and the result in Chromium was the tip text in #about-box:

htmx.ajax from a click handler
htmx.ajax('GET', '/shelf/tip', { target: '#about-box', swap: 'innerHTML' });

htmx.onLoad is the right place to initialise anything that must be set up for the new nodes of every swap, such as Alpine components or web components; see Alpine.js & Web Components. In the demo it ran once for the initial document and again for each loaded fragment.

Configuration

htmx is configured declaratively in a meta tag (preferred) or programmatically on htmx.config. The meta tag belongs in the head, before the script that loads htmx:

Configuring htmx with a meta tag
<meta name="htmx-config" content='{"defaultSwapStyle": "outerHTML"}'>

The options that matter most for a server-rendered application (htmx 2.0.11 defaults, from the reference):

Option Default Why you may change it

defaultSwapStyle

innerHTML

Swap strategy when hx-swap is absent

defaultSwapDelay, defaultSettleDelay

0, 20

Delays for CSS transitions

responseHandling

204 no swap, 2xx and 3xx swap, 4xx and 5xx error

Swap 422 (see above)

historyRestoreAsHxRequest

true

Set to false so the history restore request is not an HX-Request

historyCacheSize, refreshOnHistoryMiss

10, false

0 for no snapshots; true to reload on a miss

methodsThatUseUrlParams

["get", "delete"]

Which methods put the parameters in the URL

selfRequestsOnly

true

Allow requests to other origins only if you set this to false

allowEval

true

false removes trigger filters, hx-on: attributes, and the js: forms of hx-vals and hx-headers

allowScriptTags

true

Whether script tags in swapped content are executed

includeIndicatorStyles, inlineStyleNonce, inlineScriptNonce

true, empty, empty

Strict Content-Security-Policy setups

scrollBehavior, scrollIntoViewOnBoost, defaultFocusScroll

instant, true, false

Scrolling after swaps

globalViewTransitions

false

View transitions for every swap

timeout

0

Request timeout in milliseconds (0 = none)

allowNestedOobSwaps

true

See Out-of-band swaps

reportValidityOfForms

false

Show the browser’s validation messages for invalid forms

disableInheritance

false

Attributes are only inherited through hx-inherit

getCacheBusterParam

false

A cache-busting query parameter on htmx GET requests

withCredentials

false

Cross-site requests with cookies

CSRF

With Spring Security’s default CSRF protection, a POST, PUT, PATCH or DELETE without the token is rejected. A th:action form that is submitted by htmx gets the token as the hidden _csrf input Thymeleaf adds to it (the request body of the add form in the browser run was _csrf=<token>&title=Solaris&author=Stanislaw%20Lem). An element that is not inside such a form (the hx-delete and hx-put buttons) has no hidden input, and th:hx-post does not add a token automatically. Send it as a header for every htmx request:

  1. Put the token in meta tags, as the head fragment does (${_csrf.token} and ${_csrf.headerName}), and add it from an htmx:configRequest listener; the first lines of the script above are that listener. Every request in the Chromium run carried an X-CSRF-TOKEN header, and `GET`s, which do not need it, carry it too.

  2. Or put it in hx-headers on the body (inherited by every element). The demo’s About page does this (rendered and checked in a MockMvc test, but not exercised in the browser run); the rendered tag was <body hx-headers='{"X-CSRF-TOKEN": "<token>"}'> (the token is shortened here, and Thymeleaf wrote the quotes as " in the real output). A boosted navigation does not replace the body element itself, only what is inside it, so the meta approach is the more robust one.

The token in hx-headers on the body
<body th:hx-headers='|{"${_csrf.headerName}": "${_csrf.token}"}|'>

Do not send the token as a request parameter for hx-delete: htmx 2 puts the parameters of a DELETE in the URL, where the token would end up in access logs. The hx: Thymeleaf dialect of htmx-spring-boot-thymeleaf adds the header for you and is described on htmx with Spring Boot; the Spring Security side (CsrfToken, the SecurityFilterChain, an expired session) is on Spring Security Integration.

With the header removed on purpose (Playwright stripped X-CSRF-TOKEN from the pin request), the server rejected the request and htmx fired htmx:responseError with the status; in the scratch application it was 401 over HTTP Basic (the MockMvc test with a mock user saw 403), and with curl a POST without the token answered 401 too, while the same POST with -H "X-CSRF-TOKEN: $TOKEN" answered 200. If an ordinary form login is used the same failure is a 403.

Security

htmx makes HTML more expressive, which means HTML injected into a page can now start requests. The rules:

  • Escape everything. th:text and [[...]] escape user data; th:utext does not (see Template Security & XSS). Every fragment this page returns uses th:text. If you must inject third-party HTML, scrub attributes starting with hx- and data-hx as well as script tags, and wrap it in an element with hx-disable, which stops htmx processing the element and everything inside it; the attribute cannot be disabled by injecting further content.

  • No user data in code. hx-vals and hx-headers are JSON by default. Values given with the js: prefix are evaluated, so never splice request data into them, and do not build the JSON for hx-vals by concatenating strings from a user; use hidden inputs and hx-include. The demo’s hx-vals is a constant, and the HX-Trigger JSON contains only a generated number.

  • No view names or fragment selectors from the request. The controller maps a header or parameter to one of a fixed set of constants. The value of HX-Target is useful for logging and for choosing between constant views, never for composing a template name.

  • State changes use POST, PUT, PATCH and DELETE, never hx-get; keep CSRF on, and send the token as a header.

  • Configuration. selfRequestsOnly stays true (a request to another origin needs false or an htmx:validateUrl listener), allowEval can be false when a strict Content-Security-Policy forbids eval, allowScriptTags can be false, and hx-history="false" keeps sensitive pages out of the history snapshots. A CDN script needs SRI (see Installing) or a copy served from your own origin.

  • Responses are HTML for the swap. A login page swapped into a table is the typical symptom of an expired session; handle it with an entry point that answers htmx requests with HX-Redirect (described on Spring Security Integration).

htmx 2 compared with htmx 1.x

htmx 2.0 is deliberately close to 1.x. The migration guide lists these changes:

  • Extensions are not in the core file any more; each is a separate file and npm package (htmx-ext-sse and the others in the table above), the SSE extension must be the 2.x version, and the legacy hx-ws and hx-sse attributes are replaced by the extensions. The htmx-1-compat extension restores most of the 1.x behaviour.

  • Defaults changed: scrollBehavior is instant (it was smooth), methodsThatUseUrlParams includes delete (DELETE parameters go in the URL), and selfRequestsOnly is true (cross-domain requests need false).

  • hx-on is replaced by hx-on: attributes (the colon form, kebab-case event names): hx-on="htmx:beforeRequest: ..." becomes hx-on:htmx:before-request="...".

  • Module builds: ESM, AMD and CJS files in dist next to the browser-loadable htmx.js.

  • htmx.makeFragment() always returns a DocumentFragment, and the extension API method selectAndSwap is replaced by swap.

  • Internet Explorer is no longer supported; htmx 1.x continues to support it.

htmx 4 status

htmx 4.0.0 was published to npm on 2026-08-28 and is available only on the next tag; latest is still 2.0.11 (published 2026-09-22), and the htmx home page and this page’s examples pin 2.0.11. The documentation of htmx 4 is at https://four.htmx.org/docs/ and lists three major behaviour changes compared with htmx 2:

  • Attribute inheritance is explicit. An inherited attribute is written with the :inherited modifier (hx-confirm:inherited="Are you sure?", hx-headers:inherited=...); htmx.config.implicitInheritance set to true restores the htmx 2 behaviour.

  • Responses with 400 and 500 status codes are swapped by default, with an hx-status attribute to control per-code behaviour, and htmx.config.noSwap can restore the htmx 2 behaviour.

  • History uses a request to the server, instead of a local snapshot; the hx-history-cache extension restores local snapshots.

Event names were also standardised, htmx 4 uses fetch() instead of XMLHttpRequest, it ships an upgrade-check command-line tool (npx htmx.org@4.0.0 upgrade-check — ./path/to/project/root) and an htmx-2-compat extension. The Spring Boot library moves with it: htmx-spring-boot 6.0.0 targets htmx 4, is documented on the project’s main branch, and was not on Maven Central on 2026-10-08, so 5.2.0 is the version for htmx 2. A new application on Thymeleaf can start on htmx 2.0.11 today (everything on this page was run on it) and take on htmx 4 as a separate, deliberate upgrade once the library and the extensions you use are released for it. Re-check the dates above before relying on them: htmx 4 is the part of this page that is changing.

Pitfalls

Symptom Cause Fix

A boosted link shows a bare table inside the existing page, or the whole page nested in itself

The controller returns a fragment for every request with HX-Request: true, but a boosted request swaps the whole body, and the history restore is also an htmx request unless it is turned off

Return the fragment only when HX-Request is true and HX-Boosted is absent; set historyRestoreAsHxRequest to false; send Vary: HX-Request

After the back button the page shows only a fragment, or a cached partial

The full URL was cached with the fragment body

Vary: HX-Request (and HX-Boosted), or getCacheBusterParam; never serve a fragment for a request without HX-Request

Validation errors do not appear after a 422

4xx responses are not swapped by default (they fire htmx:responseError)

Add {"code": "422", "swap": true} to responseHandling, or use response-targets

Delete returns 204 and the row stays

A 204 is never swapped

Return 200 with an empty body for outerHTML or use hx-swap="delete" with a 200

POST from a form works, POST from a button answers 401 or 403

th:hx-post adds no CSRF token; only th:action forms get the hidden input

Add the token as a header (htmx:configRequest or hx-headers)

The CSRF token ends up in the URL of a DELETE

htmx 2 sends the parameters of a DELETE in the query string

Send the token as a header, never as a parameter

A response header (HX-Trigger, HX-Retarget) has no effect

The response was a 3xx redirect: the headers of a redirect response are not processed

Answer 200 and use HX-Redirect or HX-Location

find or closest targets nothing; htmx:targetError in the console

find searches inside the element that carries the attribute; hx-include="find ..." is resolved from the requesting element

Put the attribute on the container, or use next, previous or closest

hx-vals is ignored

The JSON is invalid (single quotes inside, unquoted keys)

Use double quotes inside and single quotes around the attribute; htmx logs an error

An hx-swap-oob element nested in the main response vanishes from its place

Nested out-of-band elements are processed by default

allowNestedOobSwaps: false, or return the fragment without the attribute (the oob parameter used above)

A tr with hx-swap-oob is dropped

A table row cannot stand alone in the HTML parser

Wrap it in a template element

Polling never stops

The server keeps returning the polling element

Return 286, or return the element without hx-trigger (load polling)

revealed never fires in a scrolling container

The trigger observes the viewport

Use intersect once with root:

The hx-trigger is not inherited, and hx-swap-oob neither

Most other attributes are inherited

Repeat them on the element; unset an inherited one on a child

Dynamic content inserted by a script has no behaviour

htmx did not process the nodes

Call htmx.process(element)

The extension does nothing

Missing hx-ext, wrong load order, or a 1.x extension

Load the 2.x file after htmx and enable it with hx-ext

Untrusted markup containing hx- attributes gets executed

htmx processes any hx-* in the page

Escape with th:text; wrap unavoidable raw HTML in hx-disable

th:each and th:replace on the same element renders unexpectedly

th:insert and th:replace have a higher precedence (order 1) than th:each (order 2)

Put th:replace on an inner element or wrap the loop in a th:block, as the demo’s results fragment does

Where the books are outdated

The consulted edition of Modern frontends with htmx is v1.0.0 (2023, htmx 1.x). Its chapters 3 to 6 (starting with htmx, request and response headers, the TodoMVC project and out-of-band swaps) are still a good read for the concepts, but code written for htmx 1.x needs these corrections: extensions are separate files since htmx 2, the parameters of a DELETE go in the URL, and the old hx-on attribute became hx-on:. The book loads htmx from a version-less webjar path (/webjars/htmx.org/dist/htmx.min.js), while this page uses the versioned path that needs no extra dependency. The book’s htmx-spring-boot code uses the 3.x API; this section uses 5.2.0 (see htmx with Spring Boot). Taming Thymeleaf was written before htmx became part of the Thymeleaf story.