htmx Patterns
|
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 Thymeleaf 3.1.5.RELEASE (2026-04-21), Spring Boot 4.1.1 (Spring Framework 7.0.9), Java 21, htmx 2.0.11 (the npm latest on 2026-10-08) and
htmx-spring-boot-thymeleaf 5.2.0 (2026-10-02), checked on 2026-10-08. The library is not managed by the Spring Boot BOM, so its version is explicit in the dependency. Every
controller, template and test shown was compiled and run in a scratch Bookshelf application (Maven wrapper, JDK 26 with --release 21), the curl commands were run against the
running application, and the behaviour that only exists in a browser (debouncing, history, the confirm dialog, the swap animation, polling, the download, the modal and the drag
and drop) was checked in Chromium driven by Playwright. The rendered output shown is the real output of those runs. The browser requests for htmx and SortableJS were answered from
local copies of the exact pinned files, so the Subresource Integrity (SRI) check in the page ran against the published bytes.
htmx runs in the browser, so the templates on this page work with Thymeleaf standalone, with Spring MVC and with Spring WebFlux alike: whatever produces the HTML fragment
(for standalone, TemplateEngine.process with a fragment selector, see Fragment Rendering & AJAX) is a valid htmx server. The
controllers use Spring MVC and htmx-spring-boot, whose request and response helpers target the servlet stack; the reactive view technology is on
Reactive Rendering with WebFlux. The attribute vocabulary used here (triggers, targets, swaps, headers) is explained on
htmx Fundamentals, and the library’s annotations, views and dialect on htmx with Spring Boot;
this page is the cookbook that applies them to the running Bookshelf scenario from Getting Started.
Each pattern follows the same shape: the template, the controller, the Source: link to the matching official htmx example, and the request and response as they really came back.
Two ideas run through all of them and are worth stating once.
-
The server returns HTML, not data. Every endpoint answers with a fragment (the
resultsfragment of thepatterns/searchtemplate, therowfragment ofpatterns/books, and so on) or, for the same URL without htmx, with the full page.@HxRequeston a second handler method with the same mapping is how the library tells the two apart; it matches only requests that carryHX-Request: true. -
State changes use POST, PUT or DELETE with the CSRF token, never GET, and every piece of user data is rendered with
th:text(see Template Security & XSS). Fragment names in the controllers are string constants; request input is only ever a lookup key.
Setting up the examples
The only dependency beyond the usual starters (spring-boot-starter-webmvc, -thymeleaf, -security, -validation) is the Thymeleaf flavour of the library, which brings
htmx-spring-boot (the annotations and views) and the hx: dialect with it:
<dependency>
<groupId>io.github.wimdeblauwe</groupId>
<artifactId>htmx-spring-boot-thymeleaf</artifactId>
<version>5.2.0</version>
</dependency>
Source: htmx-spring-boot 5.2.0 — README
Every page of the cookbook shares one <head>, kept in a fragment so that the htmx script, the CSRF metadata and the htmx configuration live in one place:
<head th:fragment="head(title)">
<meta charset="utf-8">
<title th:text="|${title} - Bookshelf|">Bookshelf</title>
<meta name="_csrf" th:content="${_csrf.token}">
<meta name="_csrf_header" th:content="${_csrf.headerName}">
<meta name="htmx-config" content='{"historyRestoreAsHxRequest": false}'>
<link rel="stylesheet" th:href="@{/css/patterns.css}">
<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 th:src="@{/js/patterns.js}"></script>
</head>
Source: htmx — Installing and htmx — Configuring htmx
The SRI hash is the one published for htmx.org@2.0.11/dist/htmx.min.js on the htmx documentation page, and I recomputed it from the downloaded file (openssl dgst -sha384 -binary | openssl base64 -A) before using it.
The htmx-config meta tag sets historyRestoreAsHxRequest to false, which the htmx documentation recommends whenever the server answers HX-Request requests with partials: a history cache miss
then asks for the full page without claiming to be a partial request (see Pagination with history).
CSRF for every state-changing request
Spring Security’s CSRF protection stays on. Forms rendered with th:action get the hidden token automatically, but an htmx request that is not a form submission must send it as a header. The
metadata above plus one small static script does that for all requests that are not GETs:
// Adds the Spring Security CSRF token to every state-changing htmx request.
document.addEventListener('htmx:configRequest', function (event) {
var token = document.querySelector('meta[name="_csrf"]');
var header = document.querySelector('meta[name="_csrf_header"]');
if (token && header && event.detail.verb !== 'get') {
event.detail.headers[header.content] = token.content;
}
});
htmx:configRequest fires after htmx has collected the parameters, and event.detail.headers and event.detail.verb are part of its detail. There are two alternatives:
-
hx-headerson<body>carries the token as JSON; the htmx documentation shows it, and warns thathx-boostdoes not update<body>, so the token must sit on an element that is replaced. -
The
hx:dialect ofhtmx-spring-boot-thymeleafwrites the header for you on any element that useshx:post,hx:put,hx:patchorhx:delete, even outside a form. The delete button of the delete pattern uses it, and the page it appears on renders:
<button type="button" hx-delete="/patterns/books/1"
hx-confirm="Remove this book from the shelf?"
hx-target="closest tr" hx-swap="outerHTML swap:1s"
hx-headers="{"X-CSRF-TOKEN":"TOKEN"}">Delete</button>
Do not put the token in the URL. The recipe of a hidden _csrf input pulled in with hx-include on a hx-delete worked with htmx 1.x, but htmx 2 sends the parameters of a DELETE in the query string, which leaks the token into logs and history.
Calling the endpoints from the shell
The Bookshelf’s form login stays in place (the demo user and password are the placeholders of the scratch application’s application.properties). The shell helper below logs in once, then reads
the session’s CSRF token from the meta tag that every pattern page renders; the later curl commands reuse $JAR and $TOKEN:
BASE=http://localhost:8080
JAR=$(mktemp)
LOGIN=$(curl -s -c "$JAR" "$BASE/login" | sed -n 's/.*name="_csrf" type="hidden" value="\([^"]*\)".*/\1/p')
curl -s -b "$JAR" -c "$JAR" -o /dev/null -d username=reader -d password=change-me \
--data-urlencode "_csrf=$LOGIN" "$BASE/login"
TOKEN=$(curl -s -b "$JAR" "$BASE/patterns/search" | sed -n 's/.*name="_csrf" content="\([^"]*\)".*/\1/p')
Active search
Active search sends a request while the user types and replaces the result list, with no submit button. The input does everything with attributes: delay:500ms waits until typing pauses,
changed skips the request when the value is the same as the last one, and an event filter adds Enter as an immediate trigger. hx-sync="this:replace" aborts a still-running request when a
newer one starts, so a slow answer can never overwrite a newer one, and hx-indicator shows the "Searching" element for the duration.
<h1>Search the shelf <span class="htmx-indicator" id="searching">Searching...</span></h1>
<input type="search" name="q" th:value="${q}" placeholder="Title or author"
th:hx-get="@{/patterns/search}"
hx-trigger="input changed delay:500ms, keyup[key=='Enter']"
hx-target="#results" hx-indicator="#searching" hx-sync="this:replace">
<table>
<thead><tr><th>Title</th><th>Author</th></tr></thead>
<tbody id="results">
<th:block th:fragment="results">
<tr th:each="book : ${results}">
<td th:text="${book.title}">Emma</td>
<td th:text="${book.author}">Jane Austen</td>
</tr>
<tr th:if="${#lists.isEmpty(results)}"><td colspan="2">No books match.</td></tr>
</th:block>
</tbody>
</table>
Source: htmx — Active Search
The fragment results is a th:block inside the <tbody>, so the response contains only table rows and the default innerHTML swap into #results is correct. One URL serves both
requests: the plain method returns the whole page for a browser, the @HxRequest method returns the fragment.
@GetMapping("/patterns/search")
String searchPage(@RequestParam(defaultValue = "") String q, Model model) {
model.addAttribute("q", q);
model.addAttribute("results", shelf.search(q));
return "patterns/search";
}
@HxRequest
@GetMapping("/patterns/search")
String searchResults(@RequestParam(defaultValue = "") String q, Model model) {
model.addAttribute("results", shelf.search(q));
return "patterns/search :: results";
}
curl -s -b "$JAR" "$BASE/patterns/search?q=bronte" | head -3
curl -s -b "$JAR" -H 'HX-Request: true' "$BASE/patterns/search?q=bronte" -D -
Without the header the response starts with <!DOCTYPE html>; with it, only the rows come back, and the response carries Vary: HX-Request, which the library adds so that browsers and
proxies do not serve the fragment to a full navigation:
HTTP/1.1 200
Vary: HX-Request
Content-Type: text/html;charset=UTF-8
<tr>
<td>Villette</td>
<td>Charlotte Bronte</td>
</tr>
<tr>
<td>Jane Eyre</td>
<td>Charlotte Bronte</td>
</tr>
...
(The response headers are trimmed to the interesting ones.) In Chromium, typing eliot at 60 ms per key produced exactly one search request. A query such as <script> is rendered as text by th:text and
simply finds no books.
Event filters such as keyup[key=='Enter'] are JavaScript expressions that htmx evaluates, so they need htmx.config.allowEval (the default) and are unavailable under a strict Content Security Policy that forbids eval.
Click to load
Click to load appends the next page when the user asks for it. The trick is the last row of every batch: it holds a button that replaces itself with the next batch, and that batch ends with a new button, until there is no next page.
<tbody>
<th:block th:fragment="rows">
<tr th:each="book : ${volumes}">
<td th:text="${book.title}">Emma</td>
<td th:text="${book.author}">Jane Austen</td>
</tr>
<tr th:if="${hasNext}" id="more-row">
<td colspan="2">
<button type="button" th:hx-get="@{/patterns/click-to-load(page=${next})}"
hx-target="#more-row" hx-swap="outerHTML" hx-disabled-elt="this">Load more books</button>
</td>
</tr>
</th:block>
</tbody>
Source: htmx — Click to Load
hx-disabled-elt="this" disables the button for the duration of the request, which prevents a double click from loading a page twice. The controller computes the page and the model once, and the
infinite scroll below reuses it:
@GetMapping("/patterns/click-to-load")
String clickToLoadPage(Model model) {
page(1, model);
return "patterns/click-to-load";
}
@HxRequest
@GetMapping("/patterns/click-to-load")
String clickToLoadRows(@RequestParam(defaultValue = "1") int page, Model model) {
page(page, model);
return "patterns/click-to-load :: rows";
}
private void page(int requested, Model model) {
List<Shelf.Volume> all = shelf.all();
int pages = Math.max(1, (all.size() + PAGE_SIZE - 1) / PAGE_SIZE);
int page = Math.min(Math.max(requested, 1), pages); // clamp: page=-3 or page=999 cannot fail
int from = (page - 1) * PAGE_SIZE;
model.addAttribute("volumes", all.subList(from, Math.min(from + PAGE_SIZE, all.size())));
model.addAttribute("page", page);
model.addAttribute("next", page + 1);
model.addAttribute("hasNext", page < pages);
}
Source: htmx — Click to Load
curl -s -b "$JAR" -H 'HX-Request: true' "$BASE/patterns/click-to-load?page=2"
<tr>
<td>Silas Marner</td>
<td>George Eliot</td>
</tr>
... (four more rows)
<tr id="more-row">
<td colspan="2">
<button type="button" hx-get="/patterns/click-to-load?page=3"
hx-target="#more-row" hx-swap="outerHTML" hx-disabled-elt="this">Load more books</button>
</td>
</tr>
In the browser, two clicks took the table from 5 to 10 to 15 rows plus the button. The same page with paging by page=6 returns the last rows and no more-row, so the button disappears.
For real data use the paging support of Spring Data (Pageable) instead of subList; see Displaying Data, Pagination & Sorting.
Infinite scroll
Infinite scroll is click to load with the click replaced by a trigger: a sentinel row holds hx-trigger="revealed", which fires when the element is scrolled into the viewport, and it
replaces itself with the next batch, whose last element is a new sentinel.
<th:block th:fragment="rows">
<tr th:each="book : ${volumes}">
<td th:text="${book.title}">Emma</td>
<td th:text="${book.author}">Jane Austen</td>
</tr>
<tr th:if="${hasNext}" th:hx-get="@{/patterns/infinite-scroll(page=${next})}"
hx-trigger="revealed" hx-swap="outerHTML">
<td colspan="2">Loading more books...</td>
</tr>
</th:block>
Source: htmx — Infinite Scroll
The official example puts the trigger on the last data row with hx-swap="afterend"; the separate sentinel row above is the same pattern without having to decide in the loop which row is the last one.
The controller has the same two methods as click to load, with the template patterns/infinite-scroll and the fragment selector rows.
curl -s -b "$JAR" -H 'HX-Request: true' "$BASE/patterns/infinite-scroll?page=2" | tail -5
<tr hx-get="/patterns/infinite-scroll?page=3"
hx-trigger="revealed" hx-swap="outerHTML">
<td colspan="2">Loading more books...</td>
</tr>
In Chromium (a 900 by 500 viewport), scrolling to the bottom repeatedly loaded all six pages, and the table ended with exactly 30 rows. Two limits of revealed are documented: it expects the
window to scroll, so in a container with overflow such as overflow-y: scroll use intersect once instead; and if a batch is shorter than the viewport the new sentinel is revealed at once, so
batches load back to back until the screen is full. Choose a page size larger than a screen.
Pagination with history
For pagination the URL is part of the application state, so the links push it into the browser history. The links stay ordinary href links (they work without JavaScript, and can be copied), and htmx
enhances them: hx-get fetches the same URL, hx-target is the page region, and hx-push-url="true" pushes the fetched URL.
<div id="page" th:fragment="page">
<table> ... rows ... </table>
<nav aria-label="Pages">
<th:block th:each="n : ${#numbers.sequence(1, pages)}">
<a th:text="${n}" th:href="@{/patterns/pagination(page=${n})}"
th:hx-get="@{/patterns/pagination(page=${n})}"
hx-target="#page" hx-swap="outerHTML" hx-push-url="true"
th:aria-current="${n == page} ? 'page'">1</a>
</th:block>
</nav>
</div>
Source: htmx — hx-push-url
The htmx documentation insists on one rule: if you push a URL, that URL must return a full page when opened directly, because the user can paste it into a new tab, and because htmx asks for the whole page when the
history cache has no snapshot (it sends HX-History-Restore-Request: true). The controller therefore returns the fragment only for a real partial request:
@GetMapping("/patterns/pagination")
String pagination(@RequestParam(defaultValue = "1") int page, HtmxRequest htmx, Model model) {
page(page, model);
int pages = (shelf.all().size() + PAGE_SIZE - 1) / PAGE_SIZE;
model.addAttribute("pages", pages);
boolean fragmentOnly = htmx.isHtmxRequest() && !htmx.isHistoryRestoreRequest();
return fragmentOnly ? "patterns/pagination :: page" : "patterns/pagination";
}
Source: htmx — History Support
HtmxRequest is the library’s view of the request headers; isHtmxRequest() is HX-Request and isHistoryRestoreRequest() is HX-History-Restore-Request. The condition is a second line of defence: with
historyRestoreAsHxRequest set to false in the page head the restore request has no HX-Request header at all, but the controller stays correct even when the configuration is missing.
curl -s -b "$JAR" "$BASE/patterns/pagination?page=3" | head -2
curl -s -b "$JAR" -H 'HX-Request: true' "$BASE/patterns/pagination?page=3" | head -2
curl -s -b "$JAR" -H 'HX-Request: true' -H 'HX-History-Restore-Request: true' "$BASE/patterns/pagination?page=3" | head -2
The first and third responses start with <!DOCTYPE html>; the second starts with <div id="page">. In Chromium, clicking page 3 and then page 5, pressing Back restored page 3
from htmx’s history snapshot without any request, and reloading the pushed URL returned the whole page with its <title>.
Inline editing
Inline editing swaps a read-only row for an editable one and back. The row fragment has an Edit button that requests the edit row (hx-get), and the edit row’s Save button sends the row’s inputs with hx-put
and hx-include="closest tr". A <form> cannot sit inside a <tr>, which is exactly why hx-include exists.
<tr th:fragment="row" th:id="|book-${volume.id}|">
<td th:text="${volume.title}">Emma</td>
<td th:text="${volume.author}">Jane Austen</td>
<td>
<button type="button" th:hx-get="@{/patterns/books/{id}/edit(id=${volume.id})}"
hx-target="closest tr" hx-swap="outerHTML">Edit</button>
...
</td>
</tr>
<tr th:fragment="edit-row" th:id="|book-${id}|" th:object="${form}">
<td>
<input name="title" th:value="*{title}" aria-label="Title" autofocus>
<small class="error" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">error</small>
</td>
<td>
<input name="author" th:value="*{author}" aria-label="Author">
<small class="error" th:if="${#fields.hasErrors('author')}" th:errors="*{author}">error</small>
</td>
<td>
<button type="button" th:hx-put="@{/patterns/books/{id}(id=${id})}" hx-include="closest tr"
hx-target="closest tr" hx-swap="outerHTML">Save</button>
<button type="button" th:hx-get="@{/patterns/books/{id}(id=${id})}"
hx-target="closest tr" hx-swap="outerHTML">Cancel</button>
</td>
</tr>
Source: htmx — Click to Edit and htmx — Edit Row
The two fragments are kept at the bottom of the page template inside a wrapper with th:remove="all", so that the full page does not render them as stray rows (Thymeleaf selects a fragment from the
unprocessed template, so the wrapper does not affect it). They could equally live in a template of their own. The page lists the rows by replacing a row with the fragment inside a loop, so the same row fragment serves the list and
the single-row responses, with the model attribute volume:
<tbody>
<th:block th:each="volume : ${volumes}">
<tr th:replace="~{:: row}"></tr>
</th:block>
</tbody>
The controller binds the edit to a record, validates it with Bean Validation, and answers an invalid save with 200 and the edit row again, error messages included:
record BookForm(@NotBlank @Size(max = 80) String title, @NotBlank @Size(max = 80) String author) { }
@HxRequest
@GetMapping("/patterns/books/{id}/edit")
String editRow(@PathVariable long id, Model model) {
Shelf.Volume v = find(id);
model.addAttribute("id", id);
model.addAttribute("form", new BookForm(v.title(), v.author()));
return "patterns/books :: edit-row";
}
@HxRequest
@PutMapping("/patterns/books/{id}")
String save(@PathVariable long id, @Valid @ModelAttribute("form") BookForm form, BindingResult errors,
Model model) {
if (errors.hasErrors()) {
model.addAttribute("id", id);
return "patterns/books :: edit-row"; // 200 with the form and its messages: htmx swaps it in
}
model.addAttribute("volume", shelf.update(id, form.title(), form.author()));
return "patterns/books :: row";
}
Source: htmx — Edit Row
The 200 matters: htmx 2 does not swap 4xx or 5xx responses (it raises htmx:responseError instead), so the common REST habit of answering a failed validation with 400 or 422 would leave the user looking at
an unchanged row. Either return 200, as here, or change htmx.config.responseHandling deliberately.
curl -s -b "$JAR" -H 'HX-Request: true' "$BASE/patterns/books/1/edit"
curl -s -b "$JAR" -H 'HX-Request: true' -H "X-CSRF-TOKEN: $TOKEN" -X PUT \
-d 'title=' -d 'author=Jane Austen' "$BASE/patterns/books/1"
The second command returns the edit row with the message next to the field (the language of the message follows the request locale; the output below is the English one):
<tr id="book-1">
<td>
<input name="title" value="" aria-label="Title" autofocus>
<small class="error">must not be blank</small>
</td>
<td>
<input name="author" value="Jane Austen" aria-label="Author">
</td>
<td>
<button type="button" hx-put="/patterns/books/1" hx-include="closest tr"
hx-target="closest tr" hx-swap="outerHTML">Save</button>
<button type="button" hx-get="/patterns/books/1"
hx-target="closest tr" hx-swap="outerHTML">Cancel</button>
</td>
</tr>
A valid save returns the read-only row with the new title, and a PUT without the CSRF header is answered with 403. Spring’s FormContentFilter (spring.mvc.formcontent.filter.enabled, on by default in Boot 4.1) is what makes the form body of a PUT available as request parameters.
See Validation & Error Messages for the messages themselves, and Alpine.js & Web Components for editing with client-side state.
Delete with confirmation and animation
The delete button combines four features: hx-delete issues the request, hx-confirm asks first with the browser’s confirm(), hx-target="closest tr" points at the row, and the swap
modifier swap:1s makes htmx wait one second between receiving the response and swapping. During that second the target carries the htmx-swapping class, which a CSS transition turns into a fade.
<button type="button" hx:delete="@{/patterns/books/{id}(id=${volume.id})}"
hx-confirm="Remove this book from the shelf?"
hx-target="closest tr" hx-swap="outerHTML swap:1s">Delete</button>
tr.htmx-swapping td {
opacity: 0;
transition: opacity 1s ease-out;
}
Source: htmx — Delete Row and htmx — Animations
hx-confirm and hx-swap are inherited by everything inside the element that declares them. The official example puts both on the <tbody>; here the table also has Edit buttons, which should neither
ask nor wait, so the attributes sit on the delete button itself.
The server answers with 200 and an empty body, which replaces the row with nothing. A 204 would not work: htmx ignores the content of 204 No Content responses by default, so nothing is swapped. The @HxTrigger annotation adds an HX-Trigger response header so that
other parts of the page can react:
@HxRequest
@HxTrigger("book-removed")
@DeleteMapping("/patterns/books/{id}")
ResponseEntity<Void> delete(@PathVariable long id) {
return shelf.delete(id) ? ResponseEntity.ok().build() : ResponseEntity.notFound().build();
}
@HxRequest
@GetMapping("/patterns/books/count")
String count(Model model) {
model.addAttribute("count", shelf.all().size());
return "patterns/books :: count";
}
The book counter listens for that event on <body> (the header triggers the event on the element that made the request, and it bubbles up to the body) and refreshes itself; the fragment that the
request returns contains the same element, so the counter keeps listening:
<span id="book-count" th:fragment="count" th:text="${count}"
th:hx-get="@{/patterns/books/count}" hx-trigger="book-removed from:body" hx-swap="outerHTML">30</span>
Source: htmx — hx-trigger
curl -s -b "$JAR" -H 'HX-Request: true' -H "X-CSRF-TOKEN: $TOKEN" -X DELETE "$BASE/patterns/books/5" -i | grep -E 'HTTP|HX-|Content-Length'
curl -s -b "$JAR" -H 'HX-Request: true' "$BASE/patterns/books/count"
HTTP/1.1 200
HX-Trigger: book-removed
Content-Length: 0
<span id="book-count" hx-get="/patterns/books/count" hx-trigger="book-removed from:body" hx-swap="outerHTML">29</span>
In Chromium the confirm dialog showed the text above, the row got the htmx-swapping class, was detached about 1.3 s after the click (the one-second swap delay plus the request), and the counter changed from 30 to 29
by itself. Without a CSRF header the same DELETE is answered with 403. An unknown id gives 404, which htmx does not swap.
Deleting from a detail page: use 303
When the delete button is on a detail page and the answer is a redirect to the list, the status code matters. A browser re-sends the same method after a 301 or 302 for requests other than POST, so
a 302 after DELETE /books/3 would make the browser send DELETE /books/3 again to the new location. Use 303 See Other, which tells the client to continue with GET:
@DeleteMapping("/patterns/books/{id}/from-detail")
ResponseEntity<Void> deleteFromDetail(@PathVariable long id) {
shelf.delete(id);
return ResponseEntity.status(HttpStatus.SEE_OTHER).location(URI.create("/patterns/pagination")).build();
}
Source: MDN — 303 See Other
HTTP/1.1 303
Location: /patterns/pagination
For a client-side redirect view the library also offers redirect:htmx: and HtmxRedirectView, covered on
htmx with Spring Boot; flash messages for non-htmx fallbacks are on Error Handling & Flash Messages.
Bulk actions
Bulk update puts a form around a table of checkboxes and sends the checked ones in one request. Because HTML inputs hold their own state, the page does not have to re-render anything that did not change. Here the action also returns the updated table, the unread counter and a toast, so the response is the next pattern.
<p>Unread: <span id="unread-count" th:fragment="counter" th:text="${unread}" hx-swap-oob="true">8</span></p>
<div id="toasts" aria-live="polite"></div>
<form>
<table id="bulk-table" th:fragment="table">
<thead><tr><th></th><th>Title</th><th>Status</th></tr></thead>
<tbody>
<tr th:each="book : ${volumes}">
<td><input type="checkbox" name="id" th:value="${book.id}" th:aria-label="|Select ${book.title}|"></td>
<td th:text="${book.title}">Emma</td>
<td th:text="${book.read} ? 'read' : 'unread'">unread</td>
</tr>
</tbody>
</table>
<button type="button" th:hx-post="@{/patterns/bulk/read}"
hx-target="#bulk-table" hx-swap="outerHTML" hx-disabled-elt="this">Mark selected as read</button>
</form>
Source: htmx — Bulk Update
A non-GET request issued by an element inside a <form> includes that form’s values, so the button needs no hx-include. The checkboxes share the name id, which Spring binds to a List<Long>:
@HxRequest
@PostMapping("/patterns/bulk/read")
FragmentsRendering markRead(@RequestParam(name = "id", defaultValue = "") List<Long> ids, Model model) {
model.addAttribute("changed", shelf.markRead(ids));
bulkModel(model);
return FragmentsRendering.with("patterns/bulk :: table")
.fragment("patterns/bulk :: counter")
.fragment("patterns/bulk :: toast")
.build();
}
FragmentsRendering (Spring Framework 6.2 and later, in org.springframework.web.servlet.view) renders each fragment with the same model and concatenates the results into one response. Ids that do not exist are ignored by
shelf.markRead, so a forged id list changes nothing it should not.
Out-of-band counters and toasts
The previous response contains three top-level elements. htmx swaps the first into the target, and treats the others as out-of-band because they carry hx-swap-oob. This is the answer to the common question
of how to update other parts of the page from one request: the main content goes where hx-target says, the extras go where their ids say.
-
hx-swap-oob="true"(the same asouterHTML) replaces the existing element that has the sameid; the counter uses it. -
A strategy as the value, such as
beforeend, uses that strategy on the element with thatid, and the response element’s own tag is stripped; the toast uses it to append a message to#toastswithout removing the older ones.
<div id="toasts" th:fragment="toast" hx-swap-oob="beforeend">
<p class="toast" role="status" th:text="${changed} + ' book(s) marked as read'">2 book(s) marked as read</p>
</div>
Source: htmx — hx-swap-oob and htmx — Updating Other Content
curl -s -b "$JAR" -H 'HX-Request: true' -H "X-CSRF-TOKEN: $TOKEN" -X POST -d id=1 -d id=2 -d id=99 "$BASE/patterns/bulk/read"
The response (the table rows are trimmed) is one table followed by the two out-of-band elements, with the toast text coming from th:text:
<table id="bulk-table">
...
<tbody>
<tr>
<td><input type="checkbox" name="id" value="1" aria-label="Select Emma"></td>
<td>Emma</td>
<td>read</td>
</tr>
...
</tbody>
</table><span id="unread-count" hx-swap-oob="true">28</span><div id="toasts" hx-swap-oob="beforeend">
<p class="toast" role="status">2 book(s) marked as read</p>
</div>
In Chromium, checking two books and pressing the button changed their status cells, moved the counter from 29 to 27 (one book had been deleted before) and added one toast. The toast CSS fades the message out with an animation, and
aria-live="polite" on the container makes screen readers announce it. Two details worth remembering: a table row cannot be an out-of-band top-level element on its own (HTML does not allow it outside a table), so
either swap the whole table as here or wrap the row in a <template> as the htmx documentation describes; and the HTML <output> element is the more accurate container when the message belongs to one form.
There are two ways to refresh other parts of the page, and the page has now used both. An event (HX-Trigger: book-removed) makes the other element fetch its own fragment; it decouples the pieces and
costs a second request. Out-of-band swaps put the other content in the same response; they are one round trip and the controller needs to know which parts depend on the action. The same split
is described for the library on htmx with Spring Boot.
Inline form validation
Inline validation checks a field when the user leaves it, using the same rules that the final submit applies. The form posts to the page as a whole, the ISBN input posts to a field endpoint, and both use the same record and the same Bean Validation annotations:
record NewBook(@NotBlank @Size(max = 80) String title,
@NotBlank @Pattern(regexp = "\\d{13}", message = "must be 13 digits") String isbn) { }
@HxRequest
@PostMapping("/patterns/validation/isbn")
String checkIsbn(@Valid @ModelAttribute("newBook") NewBook newBook, BindingResult errors) {
rejectDuplicate(newBook, errors);
return "patterns/validation :: isbn-field"; // the title errors are in the result but never rendered here
}
@PostMapping("/patterns/validation")
String submit(@Valid @ModelAttribute("newBook") NewBook newBook, BindingResult errors) {
rejectDuplicate(newBook, errors);
if (errors.hasErrors()) {
return "patterns/validation :: form";
}
shelf.add(newBook.title(), "Unknown", "Fiction", newBook.isbn());
return "patterns/validation :: saved";
}
private void rejectDuplicate(NewBook newBook, BindingResult errors) {
if (!errors.hasFieldErrors("isbn") && shelf.isbnTaken(newBook.isbn())) {
errors.rejectValue("isbn", "duplicate", "is already on the shelf");
}
}
Source: htmx — Inline Validation
The field endpoint binds the whole record, so a blank title also produces an error, but the isbn-field fragment only displays the isbn errors; nothing needs to be filtered. The submit endpoint repeats the validation:
the inline check is a convenience for the user and never a substitute for the server-side check (the field endpoint can be skipped or forged).
<form id="new-book" th:fragment="form" th:object="${newBook}" th:hx-post="@{/patterns/validation}"
hx-target="this" hx-swap="outerHTML">
<div>
<label for="title">Title</label>
<input th:field="*{title}">
<small class="error" th:if="${#fields.hasErrors('title')}" th:errors="*{title}">error</small>
</div>
<div id="isbn-field" th:replace="~{:: isbn-field}"></div>
<button type="submit">Add book</button>
</form>
<div id="isbn-field" th:fragment="isbn-field" th:object="${newBook}"
th:classappend="${#fields.hasErrors('isbn')} ? 'invalid'"
hx-target="this" hx-swap="outerHTML">
<label for="isbn">ISBN-13</label>
<input th:field="*{isbn}" th:hx-post="@{/patterns/validation/isbn}" hx-trigger="change"
hx-include="closest form" hx-sync="closest form:abort"
th:aria-invalid="${#fields.hasErrors('isbn')}">
<small class="error" th:if="${#fields.hasErrors('isbn')}" th:errors="*{isbn}">error</small>
</div>
Source: htmx — Inline Validation and htmx — hx-sync
change is the default trigger of an input and fires when the user leaves a changed field, which avoids replacing the field while the user is typing in it. hx-sync="closest form:abort" resolves
the race between a pending field check and a submit of the form: the field request is aborted if the form is being submitted. th:field supplies id, name and the value (from the rejected value after a failed binding).
curl -s -b "$JAR" -H 'HX-Request: true' -H "X-CSRF-TOKEN: $TOKEN" -X POST -d 'isbn=12ab' "$BASE/patterns/validation/isbn"
<div id="isbn-field" hx-target="this" hx-swap="outerHTML" class="invalid">
<label for="isbn">ISBN-13</label>
<input hx-post="/patterns/validation/isbn" hx-trigger="change"
hx-include="closest form" hx-sync="closest form:abort"
aria-invalid="true" id="isbn" name="isbn" value="12ab">
<small class="error">must be 13 digits</small>
</div>
A well-formed ISBN that is already taken answers is already on the shelf, and a valid submit returns <p id="saved" role="status">Book added to the shelf.</p>, replacing the form. In Chromium, entering 12ab and tabbing out
showed the message in place; a valid ISBN cleared it; submitting a valid book replaced the form with the confirmation. The messages are the Bean Validation messages of the request locale; see
Validation & Error Messages for customising them, and Forms & Data Binding for th:field and th:errors.
Dependent selects
A dependent (cascading) select refills the second <select> when the first changes. The genre select issues a GET whose parameter is the select’s own name and value, and the response contains only <option> elements
for the title select.
<select id="genre" name="genre" th:hx-get="@{/patterns/dependent/titles}" hx-target="#title">
<option th:each="g : ${genres}" th:value="${g}" th:text="${g}">Fiction</option>
</select>
<select id="title" name="title">
<th:block th:fragment="options">
<option th:each="t : ${titles}" th:value="${t.id}" th:text="${t.title}">Emma</option>
</th:block>
</select>
Source: htmx — Cascading Selects
@HxRequest
@GetMapping("/patterns/dependent/titles")
String titles(@RequestParam String genre, Model model) {
model.addAttribute("titles", shelf.byGenre(genre)); // genre is only a lookup key
return "patterns/dependent :: options";
}
Source: htmx — Cascading Selects
The genre never reaches a view name, a fragment selector or a query string: it selects books from the in-memory list (with a database it would be a bound query parameter), and an unknown value simply yields no options.
curl -s -b "$JAR" -H 'HX-Request: true' "$BASE/patterns/dependent/titles?genre=Science"
<option value="15">Opticks</option>
<option value="28">Principia</option>
<option value="16">Relativity</option>
<option value="14">The Descent of Man</option>
<option value="13">The Origin of Species</option>
The <select> is the swap target and the response is its inner HTML, so the default innerHTML swap needs no hx-swap. In Chromium, choosing Science replaced the five titles shown above. A request for
genre=../../etc/passwd returns 200 and an empty list.
Dynamic form rows
Dynamic rows let the user add inputs (here, several authors for one book) with a button. Without a framework, each Add click asks the server for one more row and appends it to the container, and a Remove button
deletes its own row on the client. The inputs share one name, so Spring binds them to a List<String> and there are no indexes to renumber.
<form th:hx-post="@{/patterns/rows}" hx-target="#authors-result">
<div id="author-rows">
<div class="author-row" th:fragment="author-row">
<input name="authors" aria-label="Author" maxlength="80">
<button type="button" hx-on:click="this.closest('.author-row').remove()">Remove</button>
</div>
</div>
<button type="button" th:hx-get="@{/patterns/rows/new}" hx-target="#author-rows" hx-swap="beforeend">Add author</button>
<button type="submit">Save</button>
</form>
<div id="authors-result"></div>
Source: htmx — hx-on and htmx — Triggers
hx-on:click runs inline JavaScript when the element is clicked, with no request involved; the script is static markup from the template, with no user data in it. The
Fragment Rendering & AJAX page builds the same feature with fetch() for comparison. Server side:
@HxRequest
@GetMapping("/patterns/rows/new")
String newRow() {
return "patterns/rows :: author-row";
}
@HxRequest
@PostMapping("/patterns/rows")
String saveRows(@RequestParam(name = "authors", defaultValue = "") List<String> authors, Model model) {
model.addAttribute("authors", authors.stream().map(String::strip).filter(s -> !s.isEmpty())
.map(s -> s.length() > 80 ? s.substring(0, 80) : s).limit(10).toList());
return "patterns/rows :: saved";
}
Source: htmx — hx-swap
Never trust the number or the length of repeated parameters: the handler strips blanks and caps the list at ten entries of 80 characters.
curl -s -b "$JAR" -H 'HX-Request: true' "$BASE/patterns/rows/new"
curl -s -b "$JAR" -H 'HX-Request: true' -H "X-CSRF-TOKEN: $TOKEN" -X POST \
-d 'authors=Jane Austen' -d 'authors= ' --data-urlencode 'authors=<b>Bram</b> Stoker' "$BASE/patterns/rows"
<div class="author-row">
<input name="authors" aria-label="Author" maxlength="80">
<button type="button" hx-on:click="this.closest('.author-row').remove()">Remove</button>
</div>
<ul>
<li>Jane Austen</li>
<li><b>Bram</b> Stoker</li>
</ul>
In Chromium, two clicks on Add author gave three inputs, Remove took one away, and saving rendered the remaining names as text, including the <b> markup shown literally.
A few other consequences are worth noting: hx-on: handlers are evaluated JavaScript, so under a strict CSP use an event listener in a script file instead, and for lists bound to a form object with indexed
names (authors[0].name) the rows need renumbering after a removal, which repeated names avoid.
Progress bar with polling, and a file download
A long-running job (here, a CSV export of the shelf with an artificial delay per row) must not hold the HTTP request open. The pattern has four steps: a POST starts the job and returns at once with 202 Accepted and a progress fragment;
that fragment polls its own status URL with hx-trigger="every 600ms"; each answer replaces the fragment; and when the job finishes the answer is a done fragment with no trigger, which stops the polling and offers the download.
The job runs on Spring Boot’s applicationTaskExecutor (a TaskExecutor), which the application can inject directly; @Async on a service method would work the same way once @EnableAsync is on. The service keeps
the jobs in a map, and a job is visible only to the user who started it:
@Service
public class ExportService {
private final Map<String, Job> jobs = new ConcurrentHashMap<>();
private final Shelf shelf;
private final TaskExecutor executor;
private final Duration rowDelay;
ExportService(Shelf shelf, TaskExecutor applicationTaskExecutor,
@Value("${bookshelf.export.row-delay:100ms}") Duration rowDelay) {
this.shelf = shelf;
this.executor = applicationTaskExecutor;
this.rowDelay = rowDelay;
}
public String start(String owner) {
List<Shelf.Volume> snapshot = shelf.all();
String id = UUID.randomUUID().toString();
Job job = new Job(owner, snapshot.size());
jobs.put(id, job);
executor.execute(() -> run(job, snapshot));
return id;
}
/** A job is visible only to the user who started it; anything else looks like "no such job". */
public Optional<Job> find(String id, String owner) {
return Optional.ofNullable(jobs.get(id)).filter(j -> j.owner.equals(owner));
}
private void run(Job job, List<Shelf.Volume> snapshot) {
StringBuilder out = new StringBuilder("id,title,author,genre\r\n");
for (Shelf.Volume v : snapshot) {
out.append(v.id()).append(',').append(cell(v.title())).append(',').append(cell(v.author()))
.append(',').append(cell(v.genre())).append("\r\n");
job.processed.incrementAndGet();
try {
Thread.sleep(rowDelay);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
job.csv = out.toString().getBytes(StandardCharsets.UTF_8);
}
/** Quotes the cell and defuses spreadsheet formulas ("CSV injection"). */
static String cell(String value) {
String safe = value;
if (!safe.isEmpty() && "=+-@\t\r".indexOf(safe.charAt(0)) >= 0) {
safe = "'" + safe;
}
return '"' + safe.replace("\"", "\"\"") + '"';
}
}
Job is a small class holding the owner, the total, an AtomicInteger of processed rows and a volatile byte[] that is set when the file is complete. A real application would also expire old jobs, persist
the result for a cluster, and cap the number of jobs per user. The cell helper matters more than it looks: a title such as =HYPERLINK("http://example.invalid") would be executed as a formula by a spreadsheet that opens
the downloaded file, so cells that start with =, +, -, @, a tab or a carriage return get a leading apostrophe (see the OWASP reference below).
@PostMapping("/patterns/export")
@ResponseStatus(HttpStatus.ACCEPTED)
String start(Principal user, Model model) {
model.addAttribute("jobId", exports.start(user.getName()));
model.addAttribute("percent", 0);
return "patterns/export :: progress";
}
@GetMapping("/patterns/export/{id}/progress")
String progress(@PathVariable String id, Principal user, Model model) {
return exports.find(id, user.getName()).map(job -> {
model.addAttribute("jobId", id);
model.addAttribute("percent", job.percent());
model.addAttribute("total", job.total());
return job.done() ? "patterns/export :: done" : "patterns/export :: progress";
}).orElse("patterns/export :: gone");
}
@GetMapping("/patterns/export/{id}/download")
ResponseEntity<byte[]> download(@PathVariable String id, Principal user) {
return exports.find(id, user.getName()).filter(ExportService.Job::done)
.map(job -> ResponseEntity.ok()
.contentType(new MediaType("text", "csv"))
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename("bookshelf.csv").build().toString())
.body(job.csv()))
.orElseGet(() -> ResponseEntity.notFound().build());
}
Source: htmx — Progress Bar
The three fragments are plain templates. Keeping the id of the progress bar stable (pb) lets htmx "settle" the changed style attribute, so a CSS transition: width .6s ease turns the 600 ms steps into a smooth animation; role="progressbar" and
aria-valuenow make the bar accessible.
<main hx-boost="true">
<h1>Export the shelf</h1>
<div id="export-status">
<button type="button" th:hx-post="@{/patterns/export}"
hx-target="#export-status" hx-swap="outerHTML" hx-disabled-elt="this">Export to CSV</button>
</div>
</main>
<div id="export-status" th:fragment="progress"
th:hx-get="@{/patterns/export/{id}/progress(id=${jobId})}" hx-trigger="every 600ms" hx-swap="outerHTML">
<p id="pblabel" role="status">Exporting... <span th:text="${percent}">40</span>%</p>
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100"
th:aria-valuenow="${percent}" aria-labelledby="pblabel">
<div id="pb" class="progress-bar" th:style="|width: ${percent}%|"></div>
</div>
</div>
<div id="export-status" th:fragment="done">
<p role="status">Export ready: <span th:text="${total}">30</span> books.</p>
<a th:href="@{/patterns/export/{id}/download(id=${jobId})}" hx-boost="false">Download bookshelf.csv</a>
<button type="button" th:hx-post="@{/patterns/export}"
hx-target="#export-status" hx-swap="outerHTML">Export again</button>
</div>
Source: htmx — Progress Bar and htmx — hx-boost
The <main> is boosted (hx-boost="true": links and forms inside use AJAX and swap the body), which is the situation in which the download link needs hx-boost="false": a boosted link would be fetched by AJAX and the CSV would be swapped into the page as text.
That is what happened when I removed the attribute in Chromium: the page body became id,title,author,genre ... and no download started. With the attribute, a normal navigation to an attachment response downloads the file and leaves the page as it was.
A plain link with no boosted ancestor needs nothing extra.
curl -s -b "$JAR" -i -H 'HX-Request: true' -H "X-CSRF-TOKEN: $TOKEN" -X POST "$BASE/patterns/export"
curl -s -b "$JAR" -H 'HX-Request: true' "$BASE/patterns/export/$ID/progress"
curl -s -b "$JAR" -D - "$BASE/patterns/export/$ID/download"
The start answers 202 with a polling fragment at 0 percent. Polling every 0.6 s against the default 100 ms delay per row produced (the polling responses reduced to their interesting lines):
<div id="export-status" hx-get="/patterns/export/e7eea076-41ec-4c9c-aae8-d12b1f4523fa/progress" hx-trigger="every 600ms" hx-swap="outerHTML">
<p id="pblabel" role="status">Exporting... <span>0</span>%</p>
...
<div id="pb" class="progress-bar" style="width: 0%"></div>
Exporting... 3% width: 3%
Exporting... 20% width: 20%
Exporting... 40% width: 40%
Exporting... 60% width: 60%
Exporting... 80% width: 80%
Exporting... 100% width: 100%
Export ready: 30 books.
The last answer, and every one after it, is the done fragment, which carries no hx-trigger and therefore ends the polling:
<div id="export-status">
<p role="status">Export ready: <span>30</span> books.</p>
<a href="/patterns/export/e7eea076-41ec-4c9c-aae8-d12b1f4523fa/download" hx-boost="false">Download bookshelf.csv</a>
<button type="button" hx-post="/patterns/export"
hx-target="#export-status" hx-swap="outerHTML">Export again</button>
</div>
The download response, whose last line comes from the MockMvc test that adds a hostile title and an author with a comma and quotes:
HTTP/1.1 200
Content-Type: text/csv
Content-Disposition: attachment; filename="bookshelf.csv"
id,title,author,genre
1,"Emma","Jane Austen","Fiction"
...
30,"Leviathan","Thomas Hobbes","History"
31,"'=HYPERLINK(""http://example.invalid"")","Eve, ""Evil""","Fiction"
In Chromium the sequence was: click, the bar grew in four to five steps, the done fragment appeared, the browser downloaded bookshelf.csv, the page stayed on /patterns/export, and no progress request was sent in the following 1.5 s. Another user’s
session asking for the same id gets the gone fragment, as if the job did not exist.
Other ways to stop polling: the htmx documentation says a server response with HTTP status 286 stops an every poll, and the official progress example uses an HX-Trigger: done response header to swap in the complete state. For
very long jobs, or progress that is pushed rather than polled, see Server-Sent Events & WebSockets.
Modals
A modal dialog is a fragment loaded on demand into an empty slot. The native <dialog> element does most of the work: showModal() makes it modal (focus trap, backdrop, Escape to close), and a form with method="dialog" closes it without JavaScript.
The only script is one hx-on attribute on the slot that opens the dialog after htmx has swapped it in:
<button type="button" th:hx-get="@{/patterns/modal/{id}(id=${book.id})}" hx-target="#modal-slot">Details</button>
<div id="modal-slot" hx-on::after-swap="this.querySelector('dialog')?.showModal()"></div>
<dialog id="book-dialog" th:fragment="dialog" aria-labelledby="book-dialog-title">
<h2 id="book-dialog-title" th:text="${volume.title}">Emma</h2>
<p th:text="${volume.author}">Jane Austen</p>
<form method="dialog"><button>Close</button></form>
</dialog>
Source: htmx — Custom Modal Dialogs and MDN — the dialog element
The official examples build the modal with CSS and Hyperscript, or with Bootstrap or UIKit components; the <dialog> version needs neither. The hx-on::after-swap shorthand (double colon) is hx-on:htmx:after-swap: it
listens for htmx:afterSwap, which htmx fires on the swap target, here the slot. The server side is an ordinary fragment endpoint with a 404 for unknown ids:
@HxRequest
@GetMapping("/patterns/modal/{id}")
String modal(@PathVariable long id, Model model) {
Shelf.Volume v = shelf.find(id);
if (v == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
model.addAttribute("volume", v);
return "patterns/modal :: dialog";
}
Source: htmx — Custom Modal Dialogs
<dialog id="book-dialog" aria-labelledby="book-dialog-title">
<h2 id="book-dialog-title">Emma</h2>
<p>Jane Austen</p>
<form method="dialog"><button>Close</button></form>
</dialog>
In Chromium the dialog matched :modal after the click and was closed again by its Close button. A modal that contains a form follows the same rules as inline validation: the form posts with hx-post and hx-target pointing at the dialog
content, and a successful save returns an empty slot (or an out-of-band update of the list) so that the dialog disappears.
Tabs
With htmx the selected tab is part of the markup the server returns, in the spirit of "hypertext as the engine of application state". Each tab button requests its tab, and the response contains the whole tab list with the new selection marked and the new panel.
<div id="tabs" th:fragment="tabs">
<div role="tablist">
<button type="button" role="tab" th:each="t : ${tabs}" th:text="${t.label}"
th:hx-get="@{/patterns/tabs/{key}(key=${t.key})}" hx-target="#tabs" hx-swap="outerHTML"
th:aria-selected="${t == selected}" aria-controls="tab-content">Details</button>
</div>
<div id="tab-content" role="tabpanel">
<p th:each="line : ${lines}" th:text="${line}">Emma, Jane Austen, 1815</p>
</div>
</div>
Source: htmx — Tabs (Using HATEOAS)
The first tab is rendered by the full page (the page includes the same tabs fragment with the first tab selected), so it works without a first request and without JavaScript. The URL segment is turned into a tab by a whitelist, never into a view
or fragment name:
enum Tab {
DETAILS("details", "Details"), REVIEWS("reviews", "Reviews"), LOANS("loans", "Loans");
...
static Tab of(String key) {
for (Tab t : values()) {
if (t.key.equals(key)) {
return t;
}
}
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
}
@HxRequest
@GetMapping("/patterns/tabs/{key}")
String tabFragment(@PathVariable String key, Model model) {
tab(Tab.of(key), model);
return "patterns/tabs :: tabs";
}
Source: htmx — Tabs (Using HATEOAS)
curl -s -b "$JAR" -H 'HX-Request: true' "$BASE/patterns/tabs/reviews"
<div id="tabs">
<div role="tablist">
<button type="button" role="tab" hx-get="/patterns/tabs/details" hx-target="#tabs" hx-swap="outerHTML"
aria-selected="false" aria-controls="tab-content">Details</button>
<button type="button" role="tab" hx-get="/patterns/tabs/reviews" ... aria-selected="true" ...>Reviews</button>
<button type="button" role="tab" hx-get="/patterns/tabs/loans" ... aria-selected="false" ...>Loans</button>
</div>
<div id="tab-content" role="tabpanel">
<p>Witty and warm <3</p>
<p>Slow start, strong finish</p>
</div>
</div>
(The buttons are shortened with ....) The review text Witty and warm <3 is escaped by th:text, and /patterns/tabs/nope is a 404. Add hx-push-url="true" to the buttons when the tab should be bookmarkable, and then
follow the full page rule of pagination. The htmx documentation also shows a variant that selects tabs with a little JavaScript, which saves a request per switch when the tab content is already on the page.
Drag and drop sorting with SortableJS
Reordering a list by dragging needs a drag-and-drop implementation, and the htmx example uses the SortableJS library. The pattern is: SortableJS fires an end DOM event when a drag finishes; the form that
contains the items has hx-trigger="end", so htmx posts the form, whose hidden inputs list the item ids in their new order; the server persists the order and returns the refreshed list.
<form class="sortable" th:hx-post="@{/patterns/reading-list/order}" hx-trigger="end">
<th:block th:fragment="items">
<div class="htmx-indicator">Saving...</div>
<div class="item" th:each="book : ${items}">
<input type="hidden" name="id" th:value="${book.id}">
<span th:text="${book.title}">Emma</span>
</div>
</th:block>
</form>
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.7/Sortable.min.js"
integrity="sha384-DgmC6Xe2bSN2WjTDXzWYbUbxyhNP+NNkGDR/g78pCXV7E7rcVTGxVg0uIVCUUcBc"
crossorigin="anonymous"></script>
<script>
htmx.onLoad(function (content) {
content.querySelectorAll(".sortable").forEach(function (list) {
var sortable = new Sortable(list, {
animation: 150,
ghostClass: "ghost",
filter: ".htmx-indicator",
onMove: function (event) {
return event.related.className.indexOf("htmx-indicator") === -1;
},
onEnd: function () {
this.option("disabled", true);
}
});
list.addEventListener("htmx:afterSwap", function () {
sortable.option("disabled", false);
});
});
});
</script>
Source: htmx — Sortable
This follows the official example: htmx.onLoad runs for the initial page and again for every piece of content htmx swaps in, so the library is initialised for lists that arrive later too; the
.htmx-indicator element is excluded from sorting; and sorting is disabled while the request is in flight (onEnd) and enabled again when the response has been swapped in (htmx:afterSwap). The SRI hash on the SortableJS script is the SHA-384 of
the downloaded sortablejs@1.15.7 file, which is the current latest on npm (the official example loads @latest without a hash; pin and hash the version you use). The script is static, so it carries no user data; with a CSP, give it a nonce or move it to a static file.
@HxRequest
@PostMapping("/patterns/reading-list/order")
String reorder(@RequestParam("id") List<Long> ids, Model model) {
try {
shelf.reorder(ids);
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
}
model.addAttribute("items", shelf.readingList());
return "patterns/reading-list :: items";
}
Source: htmx — Sortable
The service accepts only a permutation of the current list, so a forged request cannot add or drop entries:
public synchronized void reorder(List<Long> ids) {
if (ids.size() != readingList.size() || !new HashSet<>(ids).equals(new HashSet<>(readingList))) {
throw new IllegalArgumentException("not a permutation of the reading list");
}
readingList.clear();
readingList.addAll(ids);
}
Source: htmx — Sortable
curl -s -b "$JAR" -H 'HX-Request: true' -H "X-CSRF-TOKEN: $TOKEN" -X POST -d id=13 -d id=7 -d id=3 -d id=1 "$BASE/patterns/reading-list/order"
curl -s -b "$JAR" -H 'HX-Request: true' -H "X-CSRF-TOKEN: $TOKEN" -X POST -d id=1 -d id=2 "$BASE/patterns/reading-list/order" -o /dev/null -w '%{http_code}\n'
<div class="htmx-indicator">Saving...</div>
<div class="item">
<input type="hidden" name="id" value="13">
<span>The Origin of Species</span>
</div>
<div class="item">
<input type="hidden" name="id" value="7">
<span>Moby-Dick</span>
</div>
...
The second command prints 400. In Chromium, dragging the first item one position down with the mouse sent exactly one POST /patterns/reading-list/order with the CSRF header, and the server’s order afterwards equalled the order on screen.
Errors, events and progressive enhancement
htmx does not swap 4xx and 5xx responses, so a failed request can be invisible. Two events let the page say so: htmx:responseError for an HTTP error response and htmx:sendError when the request could not be sent at all (a network failure);
htmx:timeout covers a configured request timeout, and htmx:afterRequest fires after every request, successful or not (event.detail.successful and event.detail.failed tell them apart). The cookbook’s script
reports the first two in the #toasts container, building the message as text so that nothing from the response is parsed as HTML:
function toast(message) {
var box = document.getElementById('toasts');
if (!box) { return; }
var p = document.createElement('p');
p.className = 'toast';
p.setAttribute('role', 'alert');
p.textContent = message;
box.appendChild(p);
}
document.addEventListener('htmx:responseError', function (event) {
toast('The server refused the request (' + event.detail.xhr.status + ').');
});
document.addEventListener('htmx:sendError', function () {
toast('The server could not be reached.');
});
Source: htmx — Events
In Chromium, htmx.ajax('DELETE', '/patterns/books/9999', ...) (an unknown id, answered with 404) produced the toast The server refused the request (404)., and the same call with the network request aborted produced
The server could not be reached.. htmx.ajax is the JavaScript API for issuing a request without an element.
Progressive enhancement is the other design question. Plain links (<a href>) and forms (th:action) work without JavaScript, and hx-boost or hx-get on top of them only improves the experience; the pagination links are built that way, and the search, click-to-load, infinite-scroll and
pagination pages each have a full-page method next to the fragment method for browsers that do not use htmx. The Add book form is the counter-example: it posts with hx-post only, so without JavaScript it would not have a working fallback until it also gets a plain action and a full-page response. Patterns that cannot work without JavaScript (drag and drop, modals loaded on demand, polling) should be offered
only where that is acceptable, or have a plain alternative such as a separate page. A boosted container also changes how redirects, downloads and scripts behave, as the download example showed.
Calling a remote service with an HTTP Interface client
A fragment endpoint often has to ask another service for data, and that service can be slow or down. Spring’s HTTP Interface client (an interface annotated with @HttpExchange, turned into a proxy by
HttpServiceProxyFactory) keeps that call out of the controller’s body, and the controller turns a failure into a fragment the user can read. In Spring Boot 4 the RestClient.Builder bean comes from
spring-boot-starter-restclient, which the project must declare next to the web starter. The interface and its configuration:
@HttpExchange
public interface QuoteClient {
record Quote(String text, String author) { }
@GetExchange("/quotes/{id}")
Quote quote(@PathVariable long id);
}
@Configuration
public class QuoteConfig {
@Bean
QuoteClient quoteClient(RestClient.Builder builder, @Value("${quotes.base-url}") String baseUrl) {
RestClient restClient = builder.baseUrl(baseUrl).build();
return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient))
.build()
.createClient(QuoteClient.class);
}
}
The controller returns the quote fragment, or the unavailable fragment with status 503 when the remote call fails (RestClientException is the base of the client’s errors). Because htmx 2 does not swap a 4xx or 5xx response,
the htmx:responseError handler of the previous section shows the toast, and the hx-indicator element tells the user that the request is in progress:
@GetMapping("/patterns/quote/{id}")
String quote(@PathVariable long id, Model model, HttpServletResponse response) {
try {
model.addAttribute("quote", quotes.quote(id));
return "patterns/quote :: box";
} catch (RestClientException e) {
response.setStatus(HttpStatus.SERVICE_UNAVAILABLE.value());
return "patterns/quote :: unavailable";
}
}
<button type="button" hx-get="/patterns/quote/7" hx-target="#quote-box" hx-indicator="#quote-wait">Show quote</button>
<span id="quote-wait" class="htmx-indicator">Loading...</span>
<section id="quote-box"></section>
In a test, MockRestServiceServer.bindTo(builder) stands in for the remote service: a canned JSON answer produced the box fragment with status 200, and withServerError() produced the paragraph The quote service is not answering. with status 503. A
slow service is the other half of the problem: set a read timeout on the client (the request factory) so the server gives up first, and give htmx a timeout in the htmx-config meta tag so that htmx:timeout fires in the browser; both are described on
htmx Fundamentals.
Source: htmx — Events
Testing the patterns
Because every pattern is "a request with HX-Request: true, and an HTML fragment back", MockMvc tests are short and fast. They also run the real Thymeleaf templates, so a typo in a fragment selector fails the build:
@SpringBootTest(properties = "bookshelf.export.row-delay=5ms")
@AutoConfigureMockMvc
@WithMockUser(username = "reader")
class PatternsTest {
@Autowired MockMvc mvc;
@Autowired Shelf shelf;
@BeforeEach
void reset() {
shelf.reset();
}
@Test
void paginationServesPageFragmentAndHistoryRestore() throws Exception {
mvc.perform(get("/patterns/pagination").param("page", "3").header("HX-Request", "true"))
.andExpect(content().string(not(containsString("<html"))))
.andExpect(header().string("Vary", containsString("HX-Request")));
mvc.perform(get("/patterns/pagination").param("page", "3").header("HX-Request", "true")
.header("HX-History-Restore-Request", "true"))
.andExpect(content().string(containsString("<html")));
}
@Test
void deleteAndCounter() throws Exception {
mvc.perform(delete("/patterns/books/2").with(csrf()).header("HX-Request", "true"))
.andExpect(status().isOk())
.andExpect(content().string(""))
.andExpect(header().string("HX-Trigger", "book-removed"));
assertThat(shelf.find(2)).isNull();
mvc.perform(delete("/patterns/books/2").with(csrf()).header("HX-Request", "true"))
.andExpect(status().isNotFound());
}
@Test
void inlineEditingValidatesAndRequiresCsrf() throws Exception {
mvc.perform(put("/patterns/books/1").with(csrf()).header("HX-Request", "true")
.param("title", "").param("author", "Jane Austen"))
.andExpect(status().isOk())
.andExpect(content().string(containsString("class=\"error\"")));
mvc.perform(put("/patterns/books/1").header("HX-Request", "true").param("title", "x").param("author", "y"))
.andExpect(status().isForbidden());
}
}
Source: Spring Security — Testing CSRF
The eleven tests of the scratch application passed. The polling test starts an export with a 5 ms row delay, polls the progress endpoint until the body contains Export ready, downloads the file and asserts the
neutralised formula cell, then asks for the same job as a different user and expects the gone fragment. Browser-only behaviour (the debounce, the animation timing, the confirm dialog, the drag) is not visible to MockMvc; test it with a browser driver, as described on
E2E Testing with Real Browsers.
Pitfalls
| Symptom | Cause | Fix |
|---|---|---|
Every PUT, POST or DELETE does nothing on the page; the server logs 403 |
The CSRF token is not sent. htmx does not swap 4xx responses, so the failure is silent |
Send the token in a header from the |
A form that deletes returns HTTP 204 and the row stays |
htmx ignores the body of 204 No Content responses by default |
Answer a delete with 200 and an empty body |
Validation messages never appear |
The handler answers 400 or 422; htmx 2 does not swap 4xx and 5xx responses |
Return 200 with the form and its errors, or deliberately change |
Edit buttons ask for confirmation or wait one second |
|
Put the attributes on the element that needs them, or override them on the children |
After Back or a reload, the page is a bare fragment |
A URL pushed with |
Return the full page unless |
Clicking a download link replaces the page with the file’s text |
An ancestor has |
Put |
The progress bar polls forever |
The status endpoint keeps returning the fragment with |
Return a final fragment without the trigger, return HTTP 286, and give unknown jobs a fragment instead of an error status |
Infinite scroll never fires, or loads everything at once |
|
Use |
Sorting works once and then stops, or the list sorts twice |
The list is re-created by a swap and initialised twice, or the Sortable instance stays disabled |
Initialise inside |
The server redirects after a DELETE and the browser sends another DELETE |
A 301 or 302 keeps the method of a DELETE request |
Redirect with |
The browser downloads a CSV and a spreadsheet runs a formula from a title |
Cell values that start with |
Quote every cell and prefix dangerous leading characters, as in the |
A tab, modal or search term is used to build a template name such as |
Fragment and view names are evaluated as expressions; request input must not reach them |
Map the request value through a whitelist (an enum or a map) to a constant name |
User data shows up unescaped in a toast, a row or a script |
Markup built with string concatenation, |
Use |
Version notes
-
htmx 2.0.11 is the npm
lateston 2026-10-08. htmx 4.0.0 is published under thenexttag and documented at four.htmx.org; it is a separate major version that this page does not teach, andhtmx-spring-boot6.0.0, which targets it, was unreleased when this page was written (its README onmainalready describes it). The patterns here use only 2.x behaviour; read the htmx 4 documentation and the 6.0.0 README before moving to it. -
htmx-spring-boot5.2.0 is the latest release (2026-10-02). Its compatibility table lists htmx 1.x and 2.x and Spring Boot 4.0.8; the examples on this page ran on Boot 4.1.1. In the 5.x lineHtmxResponseis a controller method argument and annotations such as@HxTriggerset the response headers; the 3.x builder styleHtmxResponse.builder().view(...)of the earlier books is gone. See htmx with Spring Boot for the migration. -
The consulted edition of Modern frontends with htmx (Wim Deblauwe, v1.0.0, 2023) targets htmx 1.9, Spring Boot 3.1 and
htmx-spring-boot3.x. Its TodoMVC, client-side scripting and contacts projects are the ancestors of most patterns above. Where it is outdated: extensions and thehx-onattribute changed in htmx 2 (usehx-on:event, the singlehx-on="event: code"attribute was removed in htmx 2.0); DELETE parameters go into the URL, so the hidden_csrfplushx-includerecipe leaks the token (use a header); and the 3.x response builder API was replaced. -
Moving a fragment out of the page template (the
th:remove="all"wrapper used here) into a template of its own is equivalent; use whichever keeps your project’s fragments easier to find.