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):
Source: htmx — Requests & Responses
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-Requestrequest header, and sendsVary: HX-Requestso 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.
Source: htmx — Progressive Enhancement
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 |
Copy the file |
Download |
Webjar |
The Maven artifact |
npm / bundler |
|
Source: htmx — Installing
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.
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"
integrity="sha384-2OatzQy1H+Zd/IIrjr1TcuDGqLXeHhbooAyJY1KdQMKnr4LZ22k31GBLdYKHmVjg"
crossorigin="anonymous"></script>
Source: htmx — Via a CDN
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):
<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):
<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 |
|---|---|---|---|
|
|
2.2.4 |
Server-Sent Events from HTML ( |
|
|
2.0.4 |
WebSockets from HTML ( |
|
|
2.0.4 |
Different targets per HTTP status code ( |
|
|
2.0.5 |
Merges |
|
|
2.1.2 |
Fetches a fragment before the user asks for it |
|
|
2.0.2 |
Restores most of the htmx 1 defaults on htmx 2 |
|
|
not checked |
A |
Source: htmx — Installing Extensions
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:
<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 |
|---|---|
|
GET the URL; the parameters of the element (and of its form) go in the query string |
|
POST; a form-encoded body |
|
PUT; a form-encoded body (Spring MVC reads it as request parameters through |
|
PATCH; same body encoding as PUT |
|
DELETE; in htmx 2 the parameters go in the URL, not the body ( |
Source: htmx — AJAX
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.
<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):
<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.
Source: htmx — Introduction
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):
<!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>
Source: htmx — Configuring htmx
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:
@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);
}
Source: htmx — Caching
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:
<!-- 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):
$ 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>
Source: htmx — Request Headers
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 |
|---|---|
|
The trigger fires only once |
|
Only fires when the element’s value has changed since the last request ( |
|
Waits, and every new event resets the countdown (a debounce) |
|
Ignores further events for the interval |
|
Listens on another element, for example |
|
Only events whose target matches the selector; useful with |
|
The event does not trigger other htmx requests on ancestors |
|
What happens to events while a request is in flight: |
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.
Source: htmx — hx-trigger
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):
<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>
Source: htmx — Triggers
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:
/** 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";
}
<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>
$ 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>
Source: htmx — Request Headers
What Chromium (driven by Playwright) did with these triggers:
| Trigger | Observed |
|---|---|
|
One |
|
The pointer entered the element twice, one request was made |
|
Three keystrokes, 80 ms apart: one request, |
|
A plain click made no request; a click with Shift held made one |
|
Pressing Shift+S anywhere in the page made one request |
|
Eight keystrokes in 0.8 s: one request, for the first key ( |
|
The prompt answer arrived as |
|
Exactly three requests, then polling stopped; the 286 response ( |
|
The statistics panel was filled on page load ( |
|
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:
@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";
}
$ 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
Source: htmx — Polling
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: 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.
Source: htmx — Load Polling
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 |
|---|---|
|
The element that carries the attribute |
|
The nearest ancestor (or the element itself) that matches, for example |
|
The first descendant that matches; it searches inside the element, not around it |
|
The next sibling, or the first match found scanning forward in the document |
|
The previous sibling, or the first match scanning backwards |
|
The CSS selector wrapped in |
|
An ordinary selector; |
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:
<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="<#tg-lit-box/>">query literal</button></li>
</ul>
<div id="tg-lit-box">old</div>
</section>
Source: htmx — hx-target
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:
<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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
(empty): the target is removed whatever the response |
|
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:
<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>
Source: htmx — hx-swap
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 |
|---|---|---|
|
Delay between receiving the response and the swap (default 0 ms); the target has the |
The deleted row had |
|
Delay between swap and settle (default 20 ms); new content has |
With |
|
Does not take a |
The |
|
Scrolls the target (or |
|
|
Scrolls the target (or |
Documented, not run |
|
Scrolls to the focused input after the swap (htmx otherwise preserves focus for inputs with an |
Documented, not run |
|
Wraps the swap in the View Transitions API |
|
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.
<button id="sw-transition" type="button" th:hx-get="@{/shelf/swap/piece}" hx-target="#swap-target" hx-swap="innerHTML transition:true">transition</button>
Source: htmx — View Transitions
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 |
|---|---|
|
A CSS selector that picks part of the response to swap (the response can be a whole page) |
|
Picks further parts of the response by id for out-of-band swaps; |
|
Adds the values of other elements to the request: a selector, |
|
Extra parameters as a JSON object; a |
|
Filters the parameters: |
|
A |
|
A |
|
Which element gets the |
|
Elements that get the |
|
Coordinates requests of different elements: |
|
|
|
Extra request headers as JSON; a |
|
Forces non-form elements to validate before a request; forms are validated by default using the HTML5 Validation API |
|
Keeps an element unchanged across swaps (a playing video) |
|
Enables an extension for the element and its descendants |
|
Stops htmx processing the element and everything inside it (a security tool, see Security) |
Source: htmx — Reference, Attributes
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:
<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>
Source: htmx — hx-vals
What the browser sent (Playwright’s record of the requests; the Cookie and CSRF header are left out):
| Interaction | Request |
|---|---|
Click "Unread" on Emma |
|
Choose |
|
Click "Remove" and cancel the dialog |
The dialog text was "Remove this book from the shelf?"; no request was made |
Click "Remove" and accept |
|
The PUT reached the controller as ordinary request parameters, because Spring Boot’s FormContentFilter parses the body of PUT, PATCH and DELETE forms:
@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";
}
Source: htmx — Parameters
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:
<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>.
Source: htmx — hx-select
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.
Source: htmx — hx-indicator
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 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>
Source: htmx — Out of Band Swaps
Controller and the wire (curl; the token comes from the page, see CSRF):
@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";
}
$ 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.
Source: htmx — hx-swap-oob
Notes on out-of-band swaps:
-
By default any element with
hx-swap-oobin 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.allowNestedOobSwapsset tofalselimits 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:oobBeforeSwapandhtmx:oobAfterSwapsurround each out-of-band swap. -
hx-select-oobon 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 inhx-partialelements with anhx-target. It is in the 2.0.11 source and in the reference list, but this page does not use it; thehx-swap-oobform 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:
<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>
<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:
<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.
Source: htmx — hx-boost
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 |
Back, snapshot removed |
|
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.
Source: htmx — History Support
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 |
|---|---|
|
Always |
|
The request came from an element with |
|
Carries the |
|
Carries the |
|
Carries the |
|
The current URL of the browser |
|
The answer to an |
|
|
| Response header | Effect |
|---|---|
|
A client-side redirect without a full reload, like following an |
|
Pushes a URL onto the history |
|
Rewrites the browser’s address-bar URL without adding a history entry |
|
A client-side redirect to a new location (a full page load) |
|
|
|
A CSS selector that replaces the target of the swap |
|
Replaces the swap strategy; any |
|
A CSS selector for the part of the response to swap, overriding |
|
Triggers client-side events when the response is received |
|
Triggers events after the swap step |
|
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):
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:
// 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 |
|---|---|
|
The event log read |
|
No DOM change (204 never swaps), the toast said "Pinned" |
|
The listener read |
|
The URL became |
|
The URL was replaced |
|
The URL became |
|
A full navigation to |
|
The page reloaded (a JavaScript marker was lost) |
|
The response was appended to |
|
Only the |
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:
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";
}
<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):
$ 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.
@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();
}
$ 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
Source: htmx — Requests & Responses
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 |
|---|---|
|
New content was added to the DOM (also for the initial page); |
|
Before the request is sent: change |
|
After a trigger, before the request; can delay or cancel it ( |
|
Around the request ( |
|
Around the swap; |
|
Out-of-band swaps |
|
Errors |
|
Client-side validation |
|
History |
|
Before a view transition; cancel with |
|
Send it to an element to cancel its in-flight request |
Source: htmx — Events
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 |
|---|---|
|
Issues an htmx-style request from code and returns a Promise. |
|
Calls |
|
Makes htmx look at content that JavaScript inserted (for example with |
|
Fires an event, for example |
|
Query helpers |
|
Event listeners |
|
DOM helpers |
|
The configuration object, see Configuration |
|
Registers an extension |
Source: htmx — JavaScript API
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('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:
<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 |
|---|---|---|
|
|
Swap strategy when |
|
|
Delays for CSS transitions |
|
204 no swap, 2xx and 3xx swap, 4xx and 5xx error |
Swap 422 (see above) |
|
|
Set to |
|
|
|
|
|
Which methods put the parameters in the URL |
|
|
Allow requests to other origins only if you set this to |
|
|
|
|
|
Whether script tags in swapped content are executed |
|
|
Strict Content-Security-Policy setups |
|
|
Scrolling after swaps |
|
|
View transitions for every swap |
|
|
Request timeout in milliseconds (0 = none) |
|
|
|
|
|
Show the browser’s validation messages for invalid forms |
|
|
Attributes are only inherited through |
|
|
A cache-busting query parameter on htmx |
|
|
Cross-site requests with cookies |
Source: htmx — Configuration Options
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:
-
Put the token in
metatags, as the head fragment does (${_csrf.token}and${_csrf.headerName}), and add it from anhtmx:configRequestlistener; the first lines of the script above are that listener. Every request in the Chromium run carried anX-CSRF-TOKENheader, and `GET`s, which do not need it, carry it too. -
Or put it in
hx-headerson thebody(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 thebodyelement itself, only what is inside it, so themetaapproach is the more robust one.
<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.
Source: htmx — CSRF Prevention
Security
htmx makes HTML more expressive, which means HTML injected into a page can now start requests. The rules:
-
Escape everything.
th:textand[[...]]escape user data;th:utextdoes not (see Template Security & XSS). Every fragment this page returns usesth:text. If you must inject third-party HTML, scrub attributes starting withhx-anddata-hxas well as script tags, and wrap it in an element withhx-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-valsandhx-headersare JSON by default. Values given with thejs:prefix are evaluated, so never splice request data into them, and do not build the JSON forhx-valsby concatenating strings from a user; use hidden inputs andhx-include. The demo’shx-valsis a constant, and theHX-TriggerJSON 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-Targetis 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.
selfRequestsOnlystaystrue(a request to another origin needsfalseor anhtmx:validateUrllistener),allowEvalcan befalsewhen a strict Content-Security-Policy forbidseval,allowScriptTagscan befalse, andhx-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).
Source: htmx — Security
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-sseand the others in the table above), the SSE extension must be the 2.x version, and the legacyhx-wsandhx-sseattributes are replaced by the extensions. Thehtmx-1-compatextension restores most of the 1.x behaviour. -
Defaults changed:
scrollBehaviorisinstant(it wassmooth),methodsThatUseUrlParamsincludesdelete(DELETE parameters go in the URL), andselfRequestsOnlyistrue(cross-domain requests needfalse). -
hx-onis replaced byhx-on:attributes (the colon form, kebab-case event names):hx-on="htmx:beforeRequest: ..."becomeshx-on:htmx:before-request="...". -
Module builds: ESM, AMD and CJS files in
distnext to the browser-loadablehtmx.js. -
htmx.makeFragment()always returns aDocumentFragment, and the extension API methodselectAndSwapis replaced byswap. -
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
:inheritedmodifier (hx-confirm:inherited="Are you sure?",hx-headers:inherited=...);htmx.config.implicitInheritanceset totruerestores the htmx 2 behaviour. -
Responses with 400 and 500 status codes are swapped by default, with an
hx-statusattribute to control per-code behaviour, andhtmx.config.noSwapcan restore the htmx 2 behaviour. -
History uses a request to the server, instead of a local snapshot; the
hx-history-cacheextension 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.
Source: htmx 4 — Documentation
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 |
Return the fragment only when |
After the back button the page shows only a fragment, or a cached partial |
The full URL was cached with the fragment body |
|
Validation errors do not appear after a 422 |
4xx responses are not swapped by default (they fire |
Add |
Delete returns 204 and the row stays |
A 204 is never swapped |
Return 200 with an empty body for |
POST from a form works, POST from a button answers 401 or 403 |
|
Add the token as a header ( |
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 ( |
The response was a 3xx redirect: the headers of a redirect response are not processed |
Answer 200 and use |
|
|
Put the attribute on the container, or use |
|
The JSON is invalid (single quotes inside, unquoted keys) |
Use double quotes inside and single quotes around the attribute; htmx logs an error |
An |
Nested out-of-band elements are processed by default |
|
A |
A table row cannot stand alone in the HTML parser |
Wrap it in a |
Polling never stops |
The server keeps returning the polling element |
Return 286, or return the element without |
|
The trigger observes the viewport |
Use |
The |
Most other attributes are inherited |
Repeat them on the element; |
Dynamic content inserted by a script has no behaviour |
htmx did not process the nodes |
Call |
The extension does nothing |
Missing |
Load the 2.x file after htmx and enable it with |
Untrusted markup containing |
htmx processes any |
Escape with |
|
|
Put |
Source: htmx — Documentation
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.