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 results fragment of the patterns/search template, the row fragment of patterns/books, and so on) or, for the same URL without htmx, with the full page. @HxRequest on a second handler method with the same mapping is how the library tells the two apart; it matches only requests that carry HX-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>

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:

templates/patterns/head.html
<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>

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:

static/js/patterns.js
// 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-headers on <body> carries the token as JSON; the htmx documentation shows it, and warns that hx-boost does not update <body>, so the token must sit on an element that is replaced.

  • The hx: dialect of htmx-spring-boot-thymeleaf writes the header for you on any element that uses hx:post, hx:put, hx:patch or hx: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="{&quot;X-CSRF-TOKEN&quot;:&quot;TOKEN&quot;}">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 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.

templates/patterns/search.html
<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>

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.

templates/patterns/click-to-load.html
<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>

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

templates/patterns/infinite-scroll.html
<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>

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.

templates/patterns/pagination.html
<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>

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

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.

templates/patterns/books.html
<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>

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

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

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

templates/patterns/bulk.html
<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>

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 as outerHTML) replaces the existing element that has the same id; the counter uses it.

  • A strategy as the value, such as beforeend, uses that strategy on the element with that id, and the response element’s own tag is stripped; the toast uses it to append a message to #toasts without removing the older ones.

templates/patterns/bulk.html (toast fragment)
<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>
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");
    }
}

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

templates/patterns/validation.html
<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>

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.

templates/patterns/dependent.html
<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>
@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";
}

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.

templates/patterns/rows.html
<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>

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

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>&lt;b&gt;Bram&lt;/b&gt; 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.

sequenceDiagram participant B as Browser (htmx) participant C as ExportController participant S as ExportService participant T as Task executor thread B->>C: POST /patterns/export (HX-Request and X-CSRF-TOKEN) C->>S: start(owner) S->>T: execute(job) C-->>B: 202 progress fragment (hx-trigger every 600ms) loop every 600 ms while the job runs B->>C: GET /patterns/export/ID/progress C->>S: find(ID, owner) S-->>C: percent C-->>B: progress fragment with the new width, replaces itself end T->>S: csv ready B->>C: GET /patterns/export/ID/progress C-->>B: done fragment (no trigger, polling ends) B->>C: GET /patterns/export/ID/download (plain link, hx-boost false) C-->>B: 200 text/csv with Content-Disposition attachment

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

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.

templates/patterns/export.html
<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>

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:

templates/patterns/modal.html
<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>

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";
}
<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.

templates/patterns/tabs.html
<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>

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";
}
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 &lt;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.

templates/patterns/reading-list.html
<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>

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

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

static/js/patterns.js (continued)
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.');
});

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:

QuoteClient and QuoteConfig (the base URL is a placeholder property)
@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:

QuoteController and the button
    @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.

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

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 htmx:configRequest event, hx-headers, or the hx: dialect; keep CSRF protection on

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 htmx.config.responseHandling or handle htmx:beforeSwap

Edit buttons ask for confirmation or wait one second

hx-confirm and hx-swap are inherited from a parent such as the <tbody>

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 hx-push-url returns the fragment for every request

Return the full page unless HX-Request is true and HX-History-Restore-Request is not; set historyRestoreAsHxRequest to false and send Vary: HX-Request (the library does)

Clicking a download link replaces the page with the file’s text

An ancestor has hx-boost="true", so the link is fetched with AJAX

Put hx-boost="false" on the link, or avoid boosting the container

The progress bar polls forever

The status endpoint keeps returning the fragment with hx-trigger="every ...", or it fails with a 4xx that is never swapped

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

revealed is used in a scrolling container with overflow, or the batch is shorter than the viewport

Use intersect once inside scrolling containers; make the page size larger than a screen

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 htmx.onLoad, and re-enable the instance on htmx:afterSwap as the official example does

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 303 See Other

The browser downloads a CSV and a spreadsheet runs a formula from a title

Cell values that start with =, +, - or @ are interpreted as formulas (CSV injection)

Quote every cell and prefix dangerous leading characters, as in the cell helper

A tab, modal or search term is used to build a template name such as "patterns/" + name

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, th:utext, or values placed inside hx-on or <script>

Use th:text in fragments; pass data as data-* attributes or JSON, never concatenated into JavaScript

Version notes

  • htmx 2.0.11 is the npm latest on 2026-10-08. htmx 4.0.0 is published under the next tag and documented at four.htmx.org; it is a separate major version that this page does not teach, and htmx-spring-boot 6.0.0, which targets it, was unreleased when this page was written (its README on main already 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-boot 5.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 line HtmxResponse is a controller method argument and annotations such as @HxTrigger set the response headers; the 3.x builder style HtmxResponse.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-boot 3.x. Its TodoMVC, client-side scripting and contacts projects are the ancestors of most patterns above. Where it is outdated: extensions and the hx-on attribute changed in htmx 2 (use hx-on:event, the single hx-on="event: code" attribute was removed in htmx 2.0); DELETE parameters go into the URL, so the hidden _csrf plus hx-include recipe 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.