htmx with Spring Boot

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 io.github.wimdeblauwe:htmx-spring-boot and htmx-spring-boot-thymeleaf 5.2.0 (released on Maven Central on 2026-10-02), htmx 2.0.11, Thymeleaf 3.1.5.RELEASE, Spring Boot 4.1.1 (Spring Framework 7.0.9) and Java 21, checked on 2026-10-08. Every example was compiled and run on that stack in a scratch Bookshelf application (Maven wrapper, JDK 26 with --release 21), with MockMvc tests, curl against the running jar and a Chromium run driven by Playwright; the headers, status codes and HTML shown are the real output of those runs. The library targets Spring MVC (the servlet stack) only: its auto-configuration classes are conditional on a servlet web application, so with Spring WebFlux or a standalone Thymeleaf engine you write the plain hx-* attributes and read the HX-* headers yourself (see Reactive Rendering with WebFlux and Standalone Engine & Configuration). Version 6.0.0, which follows htmx 4, is documented on the project’s main branch but was not on Maven Central on that date.

htmx itself (attributes, triggers, swaps, the HX-* headers) is covered on htmx Fundamentals; recipes such as active search, infinite scroll and bulk actions are on htmx Patterns. This page covers the glue between htmx and a Spring Boot application: how a controller recognises an htmx request, how it sets the htmx response headers, how a template writes hx-* attributes with link expressions and a CSRF token, and how redirects, security and errors behave when the caller is htmx instead of a browser navigation. The running scenario is the Bookshelf introduced on the Getting Started page.

What the library adds

htmx is a JavaScript library: it sends an ordinary HTTP request with a few extra headers (HX-Request: true, HX-Target, HX-Trigger, …​) and understands a few extra response headers (HX-Trigger, HX-Retarget, HX-Redirect, …​). Spring MVC already renders HTML fragments, so nothing is strictly required on the server. The library removes the boilerplate of three recurring jobs.

Artifact Package Contents

htmx-spring-boot

io.github.wimdeblauwe.htmx.spring.boot.mvc

The request mapping annotation @HxRequest; the argument types HtmxRequest and HtmxResponse; the response annotations @HxTrigger, @HxTriggerAfterSettle, @HxTriggerAfterSwap, @HxPushUrl, @HxReplaceUrl, @HxReselect, @HxReswap and @HxRetarget; the views HtmxRedirectView, HtmxLocationRedirectView and HtmxRefreshView with the HtmxViewResolver behind the redirect:htmx: prefixes; the auto-configuration HtmxMvcAutoConfiguration

htmx-spring-boot

io.github.wimdeblauwe.htmx.spring.boot.security

Spring Security helpers for htmx requests: HxRedirectToPageAuthenticationEntryPoint, HxRedirectToPageAccessDeniedHandler, HxRefreshHeaderAuthenticationEntryPoint and the HxLocationRedirect* handlers for login, logout and failures

htmx-spring-boot-thymeleaf

io.github.wimdeblauwe.htmx.spring.boot.thymeleaf

The Thymeleaf dialect with the prefix hx (HtmxDialect), the #htmxRequest expression object, and the auto-configuration HtmxThymeleafAutoConfiguration that registers the dialect

Spring Web MVC, Spring Security and Thymeleaf are optional dependencies of both artifacts: the library adds nothing of its own to a project that does not already have them, so an application keeps its own starters (spring-boot-starter-webmvc, spring-boot-starter-thymeleaf, spring-boot-starter-security).

Version matrix and installation

The project’s README keeps a table of the Spring Boot generation each release line was built for. The dates below are the publication dates of the POM files on Maven Central; htmx-spring-boot and htmx-spring-boot-thymeleaf are released together with the same version number. The versions are not managed by the Spring Boot BOM, so the version is always explicit.

Library Published on Maven Central Spring Boot (README) Java htmx and notes

6.0.0

not released (2026-10-08)

4.1.x (documented on the main branch)

17

Main branch README: "htmx 4.x and later"; htmx 2.x users stay on the 5.x branch

5.2.0

2026-10-02

4.0.8

17

htmx 2.x. Adds HxRedirectToPageAuthenticationEntryPoint and HxRedirectToPageAccessDeniedHandler; used in this page on Spring Boot 4.1.1

5.1.1

2026-10-01

4.0.8

17

htmx 2.x

5.1.0

2026-03-14

4.0.3

17

htmx 2.x. Adds the automatic CSRF header of the hx: dialect

5.0.0

2025-11-27

4.0.0

17

htmx 2.x. First line for Spring Boot 4 (Jackson 3, package tools.jackson); removes the annotations @HxRedirect, @HxLocation and @HxRefresh and the class HtmxView, see Migrating from the 3.x API

4.0.3

2026-03-14

3.4.x, 3.5.x

17

The last line for Spring Boot 3; the 3.x builder API of HtmxResponse is already gone

3.6.2

2024-11-28

3.2.x

17

The 3.x line (the book Modern frontends with htmx uses an earlier 3.x release with htmx 1.9)

The "Adds" statements come from the @since tags in the 5.2.0 sources; the Boot column is the README’s, so 5.2.0 was built against Spring Boot 4.0.8 and the examples in this page run on Boot 4.1.1 without problems. Choose by Spring Boot generation first: 5.x is for Boot 4, 4.0.x for Boot 3.4 and 3.5, 3.x for older Boot 3 releases.

pom.xml (the Thymeleaf artifact brings htmx-spring-boot transitively)
<dependency>
  <groupId>io.github.wimdeblauwe</groupId>
  <artifactId>htmx-spring-boot-thymeleaf</artifactId>
  <version>5.2.0</version>
</dependency>

Without Thymeleaf (JSON endpoints, Mustache or any other view technology) depend on htmx-spring-boot alone. There is nothing to enable: both auto-configurations are registered through META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, and the jar also registers HtmxMvcAutoConfiguration for the MockMvc test slice, so @WebMvcTest sees @HxRequest too (verified below).

The htmx JavaScript library is a separate matter. The README of 5.2.0 is titled for htmx 2.x. From a CDN, pin the exact version and add the subresource integrity hash; this value was computed from the file htmx.org@2.0.11/dist/htmx.min.js served by jsDelivr (identical to the unpkg copy):

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

The examples in this page serve the same file from /js/htmx.min.js of the application instead, which also keeps a strict script-src 'self' Content Security Policy possible. Webjar, npm and extension packages are covered on htmx Fundamentals.

The Bookshelf shelf page

The example is one page that lists the books on a shelf, searches them, adds a book and removes a book, all without a page reload. The template is a single file with three named fragments (list, count and add-form) plus a small notice fragment for messages, so the same markup serves the full page and every partial response.

templates/shelf/page.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="utf-8">
  <title>Bookshelf</title>
  <meta name="htmx-config" content='{"responseHandling":[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"422","swap":true},{"code":"[45]..","swap":false,"error":true},{"code":"...","swap":false}]}'>
  <script th:src="@{/js/htmx.min.js}" defer></script>
  <script th:src="@{/js/shelf.js}" defer></script>
</head>
<body>
<main id="content">
  <h1>Bookshelf (<span id="count" th:fragment="count" hx-swap-oob="true" th:text="${count}">2</span>)</h1>
  <div id="messages" th:fragment="notice" role="status"><span th:if="${message}" th:text="${message}">Message</span></div>

  <input type="search" name="q" th:value="${q}" placeholder="Search"
         hx:get="@{/shelf}" hx-trigger="input changed delay:300ms, search"
         hx-target="#list" hx-swap="outerHTML">

  <ul id="list" th:fragment="list">
    <li th:each="b : ${books}">
      <span th:text="${b.title} + ' by ' + ${b.author}">Title by Author</span>
      <button type="button" hx:delete="@{/shelf/{id}(id=${b.id})}" hx-target="closest li" hx-swap="outerHTML"
              hx-confirm="Remove this book?">Remove</button>
    </li>
  </ul>

  <form id="add-form" th:fragment="add-form" th:object="${form}"
        hx:post="@{/shelf}" hx-target="#list" hx-swap="outerHTML"
        hx-on::after-request="if (event.detail.successful) this.reset()">
    <label>Title <input name="title" th:value="*{title}"></label>
    <span class="error" th:errors="*{title}">Title error</span>
    <label>Author <input name="author" th:value="*{author}"></label>
    <span class="error" th:errors="*{author}">Author error</span>
    <button type="submit">Add</button>
  </form>
</main>
</body>
</html>

Four details to notice before looking at the controller. The hx:get, hx:delete and hx:post attributes are processed by the dialect (link expressions, CSRF); hx-trigger, hx-target and hx-swap are plain static attributes. The count span carries hx-swap-oob="true" so a response can update it from anywhere. The htmx-config meta tag makes htmx swap 422 responses (see Forms, validation and the 422 status). And a server-rendered th:errors shows validation messages, exactly as in a non-htmx application.

Mapping htmx requests with @HxRequest

@HxRequest is a request mapping condition. A handler method carrying it is selected only when the request has an HX-Request header, which htmx adds to every request it sends. It combines with @GetMapping, @PostMapping and the other mapping annotations, and, being a plain annotation, with Spring’s meta-annotation composition (for example a custom @HxGetMapping). It can sit on a method or on a controller class.

One URL, a full page or a fragment

The classic use is a URL that renders a complete page for a browser and only the changed fragment for htmx, with the same model. Two handler methods share the URL; the more specific one (the one with the extra condition) wins when its condition holds:

flowchart TD req["GET /shelf"] --> hdr{"HX-Request header?"} hdr -- "no" --> page["page() returns shelf/page"] hdr -- "yes" --> boost{"HX-Boosted or history restore?"} boost -- "yes" --> page boost -- "no" --> frag["list() with @HxRequest returns the list fragment"]
ShelfController: one URL, two representations
    // Full page: a normal browser navigation, a boosted link or a history restore
    @GetMapping
    String page(@RequestParam(defaultValue = "") String q, Model model) {
        fill(model, q);
        model.addAttribute("form", new BookForm());
        return "shelf/page";
    }

    // The same URL for htmx: only the list is rendered
    @HxRequest(boosted = false)
    @GetMapping
    @HxPushUrl
    String list(@RequestParam(defaultValue = "") String q, Model model) {
        fill(model, q);
        return "shelf/page :: list";
    }

The second method returns the view name shelf/page :: list: Spring MVC’s ThymeleafViewResolver accepts a template name followed by a fragment selector, so only the th:fragment="list" element is rendered and the rest of the template is discarded (the fragment syntax is on Fragments; rendering fragments in other ways is on Fragment Rendering & AJAX). The selector is a constant in the source code; never build a view name or a fragment selector from a request parameter (see Template Security & XSS).

Called with curl against the running application (after signing in, with -b cj for the session cookie), the two representations differ only by the header:

curl: the same URL as a page and as a fragment
curl -b cj http://localhost:18931/shelf                       # 200, a complete HTML document
curl -b cj -D - -H 'HX-Request: true' 'http://localhost:18931/shelf?q=du'
Rendered output of the second command
HTTP/1.1 200
Vary: HX-Request
HX-Push-Url: /shelf?q=du
Content-Type: text/html;charset=UTF-8

<ul id="list">
    <li>
      <span>Dune by Frank Herbert</span>
      <button type="button" hx-delete="/shelf/1" hx-target="closest li" hx-swap="outerHTML"
              hx-confirm="Remove this book?" hx-headers="{...X-CSRF-TOKEN...}">Remove
    </li>
  </ul>

Three behaviours of the condition were checked with MockMvc and are worth knowing:

  • @HxRequest has an attribute boosted, which defaults to true: boosted requests (hx-boost, header HX-Boosted) are also matched. A boosted link swaps the whole body, so it needs the full page; the handler above therefore says @HxRequest(boosted = false), and a boosted request falls through to the page handler (verified: the response contains <html).

  • The attribute historyRestoreRequest defaults to false: when htmx restores a page from history after a cache miss it sends HX-History-Restore-Request together with HX-Request, and such a request is excluded from the handler and answered with the full page. Without that default the browser’s back button would put a bare fragment into the document.

  • The library adds Vary: HX-Request (class HtmxHandlerInterceptor) to the response of every request that has the HX-Request header. The full-page response of a request without that header gets no Vary header from the library, as the output above and the curl run show. In this application Spring Security’s default Cache-Control: no-cache, no-store headers keep both representations out of caches; if you remove them or serve the page through a CDN, add Vary: HX-Request to the page response yourself, as the htmx documentation advises.

Narrowing by trigger and target

Beyond the plain condition, @HxRequest can require a specific element. All of them compare the htmx request headers with the annotation values:

Attribute Matches the request header Meaning

value

HX-Trigger or HX-Trigger-Name

The element that triggered the request has this id or this name; use triggerId or triggerName to be explicit

triggerId

HX-Trigger

Only requests triggered by the element with this id

triggerName

HX-Trigger-Name

Only requests triggered by the element with this name

target

HX-Target

Only requests whose target element has this id (the header holds the id without the leading #)

boosted

HX-Boosted

true (the default) also matches boosted requests; false excludes them

historyRestoreRequest

HX-History-Restore-Request

false (the default) excludes history-restore requests; true also matches them

A handler for requests targeting the count element only
    // Only requests whose target element has the id "count"
    @HxRequest(target = "count")
    @GetMapping("/count")
    String count(Model model) {
        model.addAttribute("count", shelf.count());
        return "shelf/page :: count";
    }
MockMvc: the condition decides which requests reach the handler
    @Test
    void targetCondition() throws Exception {
        mvc.perform(get("/shelf/count").header("HX-Request", "true").header("HX-Target", "count"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("hx-swap-oob")));
        mvc.perform(get("/shelf/count").header("HX-Request", "true").header("HX-Target", "other"))
                .andExpect(status().isNotFound());
        mvc.perform(get("/shelf/count"))
                .andExpect(status().isNotFound());
    }

A request that satisfies the URL but not the condition gets a plain 404, as the second and third calls show; the condition is part of the request mapping, not a filter.

Reading the request: HtmxRequest

The request headers are available as a typed object. Declare an HtmxRequest parameter on any handler method (or on an @ExceptionHandler); the library’s argument resolver builds it from the current request.

Method Header Value

isHtmxRequest()

HX-Request

true if the header is present

isBoosted()

HX-Boosted

true if the request comes from an element with hx-boost

getCurrentUrl()

HX-Current-URL

The URL of the page in the browser, or null

isHistoryRestoreRequest()

HX-History-Restore-Request

true for history restoration after a cache miss

getPromptResponse()

HX-Prompt

The answer the user typed into an hx-prompt dialog, or null

getTarget()

HX-Target

Id of the target element, or null

getTriggerId()

HX-Trigger

Id of the triggered element, or null

getTriggerName()

HX-Trigger-Name

Name of the triggered element, or null

A handler that reports what it was given
    @GetMapping("/whoami")
    @ResponseBody
    String whoami(HtmxRequest htmx) {
        return "htmx=%s boosted=%s target=%s trigger=%s name=%s current=%s".formatted(
                htmx.isHtmxRequest(), htmx.isBoosted(), htmx.getTarget(),
                htmx.getTriggerId(), htmx.getTriggerName(), htmx.getCurrentUrl());
    }
MockMvc: HtmxRequest built from the request headers
    @Test
    void htmxRequestArgument() throws Exception {
        mvc.perform(get("/shelf/whoami")
                        .header("HX-Request", "true")
                        .header("HX-Target", "list")
                        .header("HX-Trigger", "search")
                        .header("HX-Trigger-Name", "q")
                        .header("HX-Current-URL", "http://localhost:8080/shelf"))
                .andExpect(content().string(
                        "htmx=true boosted=false target=list trigger=search name=q current=http://localhost:8080/shelf"));
        mvc.perform(get("/shelf/whoami"))
                .andExpect(content().string(containsString("htmx=false")));
    }

The values come straight from the headers, so treat them like any other request input: getCurrentUrl() and getPromptResponse() are chosen by the client and must never be used as a view name, a redirect target on another host or unescaped markup. In a template the same object is available as the expression object #htmxRequest (registered by the dialect, built from the request on first use), for example th:text="${#htmxRequest.htmxRequest}", which prints false for a normal request.

Setting response headers

htmx reads a set of response headers that let the server steer the swap: HX-Trigger (and its after-swap and after-settle variants) fires client events, HX-Push-Url and HX-Replace-Url update the address bar, HX-Retarget, HX-Reselect and HX-Reswap change where, what and how content is swapped. The library offers two equivalent ways to set them.

Response header Annotation HtmxResponse method

HX-Trigger

@HxTrigger("event")

addTrigger(name) and addTrigger(name, detail)

HX-Trigger-After-Swap

@HxTriggerAfterSwap("event")

addTriggerAfterSwap(name) and addTriggerAfterSwap(name, detail)

HX-Trigger-After-Settle

@HxTriggerAfterSettle("event")

addTriggerAfterSettle(name) and addTriggerAfterSettle(name, detail)

HX-Push-Url

@HxPushUrl

setPushUrl(url), preventHistoryUpdate() (sends false)

HX-Replace-Url

@HxReplaceUrl

setReplaceUrl(url)

HX-Retarget

@HxRetarget("#selector")

setRetarget(selector)

HX-Reselect

@HxReselect("#selector")

setReselect(selector)

HX-Reswap

@HxReswap(HxSwapType.OUTER_HTML) with swap, settle, scroll, show, transition, focusScroll

setReswap(HtmxReswap) with the fluent HtmxReswap.outerHtml(), innerHtml(), beforeEnd(), …​ then .swap(Duration), .settle(Duration), .scroll(Position), .show(Position), .transition()

HX-Redirect, HX-Location and HX-Refresh are deliberately not on HtmxResponse; they end the request and are produced by views (next section). Annotations are static and fit headers that never change for a handler; the HtmxResponse argument is for values computed at run time. Both work with the same handler.

Annotations: fixed headers
    // Static headers with annotations
    @HxRequest
    @GetMapping("/annotations")
    @HxTrigger("shelf-changed")
    @HxTriggerAfterSwap("list-swapped")
    @HxTriggerAfterSettle("list-settled")
    @HxPushUrl("/shelf?view=all")
    @HxRetarget("#list")
    @HxReselect("#list")
    @HxReswap(value = HxSwapType.OUTER_HTML, swap = 200, transition = true)
    String annotations(Model model) {
        model.addAttribute("count", 0);
        return "shelf/page :: count";
    }
curl output of the annotated handler
HTTP/1.1 200
Vary: HX-Request
HX-Push-Url: /shelf?view=all
HX-Reswap: outerHTML transition:true swap:200ms
HX-Retarget: #list
HX-Reselect: #list
HX-Trigger: shelf-changed
HX-Trigger-After-Settle: list-settled
HX-Trigger-After-Swap: list-swapped
Content-Type: text/html;charset=UTF-8

<span id="count" hx-swap-oob="true">0</span>

@HxPushUrl and @HxReplaceUrl without a value use the current request URL including its query string (the search handler above pushed /shelf?q=du). A value starting with / gets the context path prepended unless contextRelative = false. The value false is how preventHistoryUpdate() stops htmx from pushing.

HtmxResponse as a method argument
    // The same headers, computed at run time
    @HxRequest
    @GetMapping("/dynamic")
    String dynamic(Model model, HtmxResponse htmx) {
        model.addAttribute("count", 0);
        Map<String, Object> detail = new LinkedHashMap<>();
        detail.put("id", 7);
        detail.put("title", "Emma");
        htmx.addTrigger("book-added", detail);
        htmx.addTrigger("shelf-changed");
        htmx.addTriggerAfterSettle("settled");
        htmx.setReplaceUrl("/shelf?view=dynamic");
        htmx.setReswap(HtmxReswap.beforeEnd().swap(Duration.ofMillis(100)).scroll(HtmxReswap.Position.TOP));
        return "shelf/page :: count";
    }
curl output of the dynamic handler
HTTP/1.1 200
Vary: HX-Request
HX-Trigger: {"book-added":{"id":7,"title":"Emma"},"shelf-changed":null}
HX-Trigger-After-Settle: settled
HX-Replace-Url: /shelf?view=dynamic
HX-Reswap: beforeend swap:100ms scroll:top
Content-Type: text/html;charset=UTF-8

<span id="count" hx-swap-oob="true">0</span>
MockMvc: the same headers asserted
    @Test
    void annotationsSetResponseHeaders() throws Exception {
        mvc.perform(get("/shelf/demo/annotations").header("HX-Request", "true"))
                .andExpect(header().string("HX-Trigger", "shelf-changed"))
                .andExpect(header().string("HX-Trigger-After-Swap", "list-swapped"))
                .andExpect(header().string("HX-Trigger-After-Settle", "list-settled"))
                .andExpect(header().string("HX-Push-Url", "/shelf?view=all"))
                .andExpect(header().string("HX-Retarget", "#list"))
                .andExpect(header().string("HX-Reselect", "#list"))
                .andExpect(header().string("HX-Reswap", "outerHTML transition:true swap:200ms"));
        mvc.perform(get("/shelf/demo/replace").header("HX-Request", "true"))
                .andExpect(header().string("HX-Replace-Url", "/shelf/demo/replace"));
    }

    @Test
    void htmxResponseArgumentSetsTheSameHeadersAtRunTime() throws Exception {
        mvc.perform(get("/shelf/demo/dynamic").header("HX-Request", "true"))
                .andExpect(header().string("HX-Trigger", "{\"book-added\":{\"id\":7,\"title\":\"Emma\"},\"shelf-changed\":null}"))
                .andExpect(header().string("HX-Trigger-After-Settle", "settled"))
                .andExpect(header().string("HX-Replace-Url", "/shelf?view=dynamic"))
                .andExpect(header().string("HX-Reswap", "beforeend swap:100ms scroll:top"));
    }

Notes from the run:

  • When several triggers are added, or one has a detail, the header becomes a JSON object ({"book-added":{...},"shelf-changed":null}); a plain list of names is sent as a comma-separated value. On the page, the event is dispatched on the element that issued the request and bubbles, so document.addEventListener('book-added', e => ...) receives it with e.detail.id (verified in Chromium; the detail object holds the parsed JSON plus htmx’s own elt reference, and JSON.stringify on it throws "Converting circular structure to JSON").

  • The JSON written to a header escapes non-ASCII characters (\uXXXX), because header values must be ASCII.

  • The headers are written by an interceptor and, for @ResponseBody and ResponseEntity returns, by a ResponseBodyAdvice. They are available in @ExceptionHandler methods too (the duplicate-title handler below sets two).

  • htmx does not process response headers on 3xx responses; the browser follows the redirect before htmx sees it. A handler that wants to set HX-Trigger and also send the user elsewhere must answer 200, which is what the views below do.

  • The HtmxResponse type is not a return value in 5.x: a method declares it as a parameter and fills it.

Redirects, locations and refresh

After a successful POST a browser application sends a 302 (Post/Redirect/Get). With htmx, fetch/XMLHttpRequest follows a 302 transparently, and the page the server redirects to would be swapped into the target element, a full document in the middle of a list. htmx has three response headers for this situation, and the library wraps each in a view:

Response header View class View name prefix Effect in the browser

HX-Redirect

HtmxRedirectView

redirect:htmx:/path

A full navigation to the URL, as if the user had followed a link

HX-Location

HtmxLocationRedirectView

redirect:htmx:location:/path

An ajax navigation: htmx fetches the URL and swaps body (or the given target), pushing history, without a full reload

HX-Refresh

HtmxRefreshView

refresh:htmx

A full reload of the current page

The views extend Spring’s RedirectView, so they keep its features (model attributes as query parameters, flash attributes through RedirectAttributes, a context-relative URL). The string prefixes are resolved by HtmxViewResolver, which the auto-configuration registers with a low precedence; define your own HtmxViewResolver bean to change setRedirectContextRelative or setRedirectHosts.

NavigationController: the three views and the progressive-enhancement twin
    // HX-Redirect: full page navigation
    @HxRequest
    @PostMapping("/redirect")
    String redirect() {
        return "redirect:htmx:/shelf";
    }

    // The progressive-enhancement twin: a plain form post gets a normal 302
    @PostMapping("/redirect")
    String redirectWithoutHtmx() {
        return "redirect:/shelf";
    }

    // HX-Location: an ajax navigation that swaps #content
    @HxRequest
    @PostMapping("/location")
    View location() {
        HtmxLocationRedirectView view = new HtmxLocationRedirectView("/shelf", true);
        view.setTarget("#content");
        view.setSwap("innerHTML");
        return view;
    }

    @HxRequest
    @PostMapping("/location-by-name")
    String locationByName() {
        return "redirect:htmx:location:/shelf";
    }

    // HX-Refresh
    @HxRequest
    @PostMapping("/refresh")
    String refresh() {
        return "refresh:htmx";
    }
curl output (each command is a POST with the session cookie, the CSRF header and HX-Request)
POST /shelf/go/redirect              ->  200  HX-Redirect: /shelf
POST /shelf/go/location              ->  200  HX-Location: {"path":"/shelf","swap":"innerHTML","target":"#content"}
POST /shelf/go/location-by-name      ->  200  HX-Location: /shelf
POST /shelf/go/refresh               ->  200  HX-Refresh: true
POST /shelf/go/redirect (no HX-Request) ->  302  Location: http://localhost:18931/shelf

HtmxLocationRedirectView also exposes the htmx context for HX-Location as setters: setSource, setEvent, setHandler, setTarget, setSwap, setSelect, setValues and setHeaders. The header stays a plain path while none of them is set and becomes a JSON object as soon as one is, as the second output line shows.

Why the second redirect() method in the controller? @HxRequest handlers are invisible to a request without the HX-Request header, so a plain <form> post (JavaScript disabled, a crawler, curl) would get a 404 without the twin; with it, the same URL redirects normally. Whether that progressive-enhancement cost is worth paying is a design decision for each application.

Several fragments and out-of-band swaps

A single view renders a single template, but htmx can update several places from one response: the main content goes into the target and any top-level element with hx-swap-oob replaces the element with the same id elsewhere on the page. In the add handler the response carries the new list and the book counter in the page heading.

ShelfController: the list fragment plus the count fragment in one response
    @HxRequest
    @PostMapping
    View add(@Valid @ModelAttribute("form") BookForm form, BindingResult errors,
             Model model, HtmxResponse htmx) {
        if (errors.hasErrors()) {
            htmx.setRetarget("#add-form");
            htmx.setReswap(HtmxReswap.outerHtml());
            return FragmentsRendering.with("shelf/page :: add-form")
                    .status(HttpStatus.UNPROCESSABLE_CONTENT)
                    .build();
        }
        ShelfBook saved = shelf.add(form.getTitle(), form.getAuthor());
        fill(model, "");
        htmx.addTrigger("book-added", Map.of("id", saved.id()));
        return FragmentsRendering.with("shelf/page :: list")
                .fragment("shelf/page :: count")
                .build();
    }

FragmentsRendering (Spring Framework 6.2 and later, org.springframework.web.servlet.view.FragmentsRendering) renders each named fragment with the handler’s model and concatenates the results into one response; the first fragment is the main content and the count fragment is the out-of-band one. In 5.x this replaces the template-collecting methods that HtmxResponse had in the 3.x API. The alternative return type Collection<ModelAndView> does the same with a separate model per fragment, and the README shows both. Both are also covered on Fragment Rendering & AJAX.

curl output: POST /shelf with title and author (HX-Request and the CSRF header set)
HTTP/1.1 200
Vary: HX-Request
HX-Trigger: {"book-added":{"id":3}}
Content-Type: text/html;charset=UTF-8

<ul id="list">
    <li>
      <span>Dune by Frank Herbert</span>
      ...
    </li>
    <li>
      <span>Hamlet by William Shakespeare</span>
      ...
    </li>
  </ul><span id="count" hx-swap-oob="true">3</span>

The out-of-band element is the same th:fragment="count" span that the full page renders, with the attribute included in the markup. The attribute is inert on a normal page load (in the Chromium run the counter showed the right number after a normal page load) and is acted on only when htmx finds it in a response to one of its requests. hx-swap-oob accepts any swap strategy and a selector (beforeend:#toasts) for appending, not only true; see htmx Patterns for counters and toast notifications built that way.

The same technique serves delete: the delete button targets its closest li with outerHTML, and the handler returns only the count fragment. htmx removes the li because the main content of the response is empty, and the out-of-band element updates the counter.

The delete handler
    @HxRequest
    @DeleteMapping("/{id}")
    View remove(@PathVariable long id, Model model, HtmxResponse htmx) {
        if (!shelf.remove(id)) {
            throw new ResponseStatusException(HttpStatus.NOT_FOUND);
        }
        model.addAttribute("count", shelf.count());
        htmx.addTrigger("book-removed");
        return FragmentsRendering.with("shelf/page :: count").build();
    }
MockMvc: removing a book and removing it twice
    @Test
    void removeAndMissing() throws Exception {
        ShelfBook temp = shelf.add("Ulysses", "James Joyce");
        mvc.perform(delete("/shelf/" + temp.id()).with(csrf()).header("HX-Request", "true"))
                .andExpect(status().isOk())
                .andExpect(header().string("HX-Trigger", "book-removed"))
                .andExpect(content().string(containsString("hx-swap-oob")))
                .andExpect(content().string(not(containsString("Ulysses"))));
        mvc.perform(delete("/shelf/" + temp.id()).with(csrf()).header("HX-Request", "true"))
                .andExpect(status().isNotFound());
        mvc.perform(delete("/shelf/1").header("HX-Request", "true"))
                .andExpect(status().isForbidden());
    }

htmx ignores the content of a 204 response and does not swap it; an empty 200 swaps an empty string, which is what removes the element here. For a delete that returns nothing, return an empty body with 200, or set hx-swap="delete" on the button as the htmx documentation describes.

The hx: Thymeleaf dialect

htmx-spring-boot-thymeleaf registers a dialect named "Htmx" with the prefix hx. For every processed attribute it provides an hx: twin whose value is a normal Thymeleaf expression and whose output is the static hx- attribute. The colon is the difference: hx:get is processed by Thymeleaf, hx-get is written through unchanged.

The dialect has processors for these attributes (taken from the 5.2.0 sources): hx:boost, hx:confirm, hx:delete, hx:disable, hx:disinherit, hx:encoding, hx:ext, hx:get, hx:headers, hx:history-elt, hx:include, hx:indicator, hx:params, hx:patch, hx:post, hx:preserve, hx:prompt, hx:put, hx:push-url, hx:request, hx:select, hx:swap, hx:swap-oob, hx:sync, hx:target, hx:trigger, hx:validate, hx:vals and hx:vars. Other attributes, such as hx-disabled-elt, hx-replace-url or hx-on, have no hx: twin and are written as static hx-* attributes; so are attributes whose value is a constant, which is the common case for hx-target, hx-swap and hx-trigger.

Where the dialect earns its place is @{...}: link expressions resolve the context path, path variables and URL parameters, and pass through RequestDataValueProcessor, so the URLs are correct under a context path and session-encoded where needed.

templates/shelf/dialect.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<body>
<a hx:get="@{/shelf/{id}(id=${id})}" hx-target="#main">Details</a>
<button hx:post="@{/shelf/{id}/borrow(id=${id})}" hx:vals="${ {bookId: id, source: 'shelf'} }">Borrow</button>
<button hx:delete="@{/shelf/{id}(id=${id})}" hx-headers='{"X-Trace": "t1"}'>Remove</button>
<button hx:get="@{/shelf}" hx:target="${'#list'}" hx-swap="outerHTML">Reload</button>
<button hx:vals="${ {title: title} }" hx:post="@{/shelf}">Escaped</button>
<p th:text="${#htmxRequest.htmxRequest}">false</p>
</body>
</html>
Rendered output (the book title is the model value Dune "Messiah" <b>; the CSRF token is elided)
<a hx-get="/shelf/42" hx-target="#main">Details</a>
<button hx-post="/shelf/42/borrow" hx-vals="{&quot;bookId&quot;:42,&quot;source&quot;:&quot;shelf&quot;}" hx-headers="{&quot;X-CSRF-TOKEN&quot;:&quot;<token>&quot;}">Borrow</button>
<button hx-delete="/shelf/42" hx-headers='{&quot;X-Trace&quot;:&quot;t1&quot;,&quot;X-CSRF-TOKEN&quot;:&quot;<token>&quot;}'>Remove</button>
<button hx-get="/shelf" hx-target="#list" hx-swap="outerHTML">Reload</button>
<button hx-vals="{&quot;title&quot;:&quot;Dune \&quot;Messiah\&quot; &lt;b&gt;&quot;}" hx-post="/shelf" hx-headers="{&quot;X-CSRF-TOKEN&quot;:&quot;<token>&quot;}">Escaped</button>
<p>false</p>

What the output demonstrates, and what the sources of the processor confirm:

  • A java.util.Map result is converted to JSON and entries with a null key or value are dropped. hx:vals="${ {bookId: id, source: 'shelf'} }" is a Thymeleaf inline map (SpEL), and the JSON is what hx-vals requires. Any other result is written with toString().

  • The value is HTML-escaped (HtmlEscape.escapeHtml4Xml) before it becomes the attribute, so a model value containing quotes or <b> stays inside the attribute and cannot break out of it. The JSON string escaping (\") is applied first. This is the safe way to pass server data to htmx; never concatenate model values into an hx-vals or hx-on string by hand.

  • If the expression evaluates to null, the attribute is removed.

  • The hx:target="${'#list'}" form is needed for selectors: hx:target="#list" fails with Could not parse as expression: "#list" because Thymeleaf reads # as the start of an expression object or message. Use the static hx-target="#list", or quote the selector in an expression as shown.

  • The #htmxRequest expression object is available in the same dialect.

Automatic CSRF on mutating requests

Since 5.1.0 the processors for hx:post, hx:put, hx:patch and hx:delete also add the Spring Security CSRF token to the element’s hx-headers. The processor reads the request-scoped variable _csrf (a Spring Security CsrfToken), writes token.getHeaderName() (with the default repository X-CSRF-TOKEN) and the token into the JSON of hx-headers, and keeps headers that the element already had (the X-Trace header above). Nothing is added for hx:get, because GET requests are not CSRF protected, and nothing when there is no _csrf variable (Spring Security absent or CSRF disabled).

MockMvc: the token appears on mutating requests only
    @Test
    @WithMockUser
    void csrfTokenIsRenderedByTheDialectOnMutatingRequestsOnly() throws Exception {
        String html = mvc.perform(get("/shelf/demo/dialect")).andReturn().getResponse().getContentAsString();
        assertThat(html)
                .contains("hx-post=\"/shelf/42/borrow\" hx-vals=\"{&quot;bookId&quot;:42,&quot;source&quot;:&quot;shelf&quot;}\" hx-headers=\"{&quot;X-CSRF-TOKEN&quot;")
                .contains("<button hx-get=\"/shelf\" hx-target=\"#list\" hx-swap=\"outerHTML\">Reload</button>");
    }

The token is added per element, so it is repeated for every button of a list and the page grows with it (see the response above). Spring Security’s default request handler adds fresh randomness to the token on every request (BREACH protection, see the Spring Security reference), so the value differs from response to response and the examples elide it; any valid masked value for the session is accepted. The attribute is added only for elements that use an hx: mutating attribute: a static hx-post="..." gets no token and the request is rejected (next section).

Forms, validation and the 422 status

A form posted with htmx is an ordinary Spring MVC form: @Valid, BindingResult and th:errors work unchanged. The decision is what to do on a validation error. The convention that works with htmx is a 422 Unprocessable Content response with the form re-rendered, retargeted to the form itself:

The error branch of the add handler (shown in full above)
        if (errors.hasErrors()) {
            htmx.setRetarget("#add-form");
            htmx.setReswap(HtmxReswap.outerHtml());
            return FragmentsRendering.with("shelf/page :: add-form")
                    .status(HttpStatus.UNPROCESSABLE_CONTENT)
                    .build();
        }
curl output: POST /shelf with an empty title
HTTP/1.1 422
Vary: HX-Request
HX-Retarget: #add-form
HX-Reswap: outerHTML
Content-Type: text/html;charset=UTF-8

<form id="add-form" hx-post="/shelf" hx-target="#list" hx-swap="outerHTML"
        hx-on::after-request="if (event.detail.successful) this.reset()" hx-headers="{...}">
    <label>Title <input name="title" value=""></label>
    <span class="error">must not be blank</span>
    <label>Author <input name="author" value="x"></label>

    <button type="submit">Add</button>
  </form>

The message language follows the Accept-Language header (the output above is the English one); see Validation & Error Messages and Forms & Data Binding.

Two details make this work. HX-Retarget and HX-Reswap send the response to the form instead of the list, with the form replaced (outerHTML) so the error spans appear. And htmx must be told to swap a 422: by default responses with a 4xx or 5xx status are not swapped, see Error handling.

CSRF with htmx

Spring Security checks a CSRF token on POST, PUT, PATCH and DELETE. A form with th:action gets a hidden _csrf input automatically; htmx requests that do not come from such a form need the token in some other place. The server accepts it as a request parameter (_csrf) or as a header (X-CSRF-TOKEN by default). The options:

Approach How Notes

Form with th:action

The hidden input is part of the form and is sent in the body of a POST

Works for forms that htmx submits and keeps the form working without JavaScript

hx:post, hx:put, hx:patch, hx:delete

The dialect writes the header into hx-headers

Nothing else to write; the token is repeated per element (see above)

Meta tags and htmx:configRequest

The token and the header name are rendered once into <meta> tags and a small script copies them into every request

One token per page; works with static hx-* attributes

hx-headers on body or html

The htmx documentation shows hx-headers with the token on an ancestor element

hx-boost does not replace html or body, so the token must live on an element that is replaced

The meta-tag approach in full. The template renders the token, th:content escapes it, and a script reacts to the htmx event that fires before each request is sent:

templates/shelf/csrf.html
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
  <meta charset="utf-8">
  <title>CSRF with htmx</title>
  <meta name="_csrf" th:content="${_csrf.token}">
  <meta name="_csrf_header" th:content="${_csrf.headerName}">
  <script th:src="@{/js/htmx.min.js}" defer></script>
</head>
<body>
<!-- A: hx: dialect, token in an hx-headers attribute -->
<button id="dialect" hx:delete="@{/shelf/demo/probe}" hx-swap="none">hx: dialect</button>

<!-- B: plain hx-delete, token from the meta tags by the script below -->
<button id="meta" hx-delete="/shelf/demo/probe" hx-swap="none">Meta tags</button>

<!-- C: the hidden input and hx-include: do not do this with DELETE -->
<form id="f">
  <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
  <button id="include" type="button" hx-delete="/shelf/demo/probe" hx-include="#f" hx-swap="none">hx-include</button>
</form>
<script th:src="@{/js/csrf-meta.js}" defer></script>
</body>
</html>
static/js/csrf-meta.js
document.addEventListener('htmx:configRequest', (event) => {
  const token = document.querySelector('meta[name="_csrf"]');
  const header = document.querySelector('meta[name="_csrf_header"]');
  if (token && header) {
    event.detail.headers[header.content] = token.content;
  }
});

The three buttons issue a DELETE to a probe endpoint. Chromium (driven by Playwright) recorded the requests the browser sent after clicking each one:

Playwright: what each CSRF approach sent
hx: dialect   DELETE /shelf/demo/probe                   header x-csrf-token present   -> 200
meta tags     DELETE /shelf/demo/probe                   header x-csrf-token present   -> 200
hx-include    DELETE /shelf/demo/probe?_csrf=<token>     header x-csrf-token absent    -> 200

A correction for the book: the token in the URL

Modern frontends with htmx (written for htmx 1.9) solves the missing token of a delete button without a form by adding a hidden input and pointing hx-include at it. That was a body parameter in htmx 1.x. In htmx 2 the third line above is what happens: DELETE requests, like GET, put their parameters in the URL (htmx.config.methodsThatUseUrlParams defaults to ["get", "delete"]; the htmx 1 to 2 migration guide lists it as a breaking change). The server accepts the _csrf request parameter wherever it is, so nothing breaks, which is exactly the problem: the token ends up in access logs, proxy logs, monitoring and browser history. Do not use hx-include for the CSRF token of a DELETE. Use the header, either with hx:delete or with the meta tags. (Reverting methodsThatUseUrlParams to ["get"] would bring the body back but changes every DELETE in the application.)

Session expiry

When the session has expired, htmx requests do not get the browser’s usual handling. A GET is answered with a redirect to the login page, which htmx follows and swaps into the target element. A POST fails its CSRF check (the token lived in the session), the 403 is not swapped, and nothing happens on the page. In both cases the user sees a broken page, not a login form.

sequenceDiagram participant B as Browser with htmx participant S as Spring Security B->>S: POST /shelf (HX-Request and HX-Current-URL, session expired) S->>S: CsrfFilter finds no token for the session S-->>B: 403 with HX-Redirect to /shelf B->>S: GET /shelf (a normal navigation) S-->>B: 302 to /login B->>S: GET /login S-->>B: the login page replaces the document

The library has an entry point and an access denied handler for this, new in 5.2.0:

SecurityConfig
@Configuration
public class SecurityConfig {
    @Bean
    SecurityFilterChain chain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(a -> a.requestMatchers("/login", "/css/**", "/js/**").permitAll().anyRequest().authenticated())
            .formLogin(login -> login.loginPage("/login"))
            .exceptionHandling(exceptions -> exceptions
                .authenticationEntryPoint(new HxRedirectToPageAuthenticationEntryPoint(
                        new LoginUrlAuthenticationEntryPoint("/login")))
                .accessDeniedHandler(new HxRedirectToPageAccessDeniedHandler()));
        return http.build();
    }
}

HxRedirectToPageAuthenticationEntryPoint wraps the entry point the application uses for other requests (here the login page’s). Requests without HX-Request go to the wrapped entry point unchanged. For an htmx request it answers 401 with HX-Redirect to the page the request was made from (the path and query of HX-Current-URL, or the link itself for a boosted GET), and HX-Refresh: true when no usable page is known. The browser then loads that page as a navigation, Spring Security redirects it to the login form, and after signing in the user returns to the page. HxRedirectToPageAccessDeniedHandler does the same for a CSRF failure caused by a missing token (the session that held it is gone); every other denial, such as a signed-in user without the role, goes to its delegate. Only the path and query of HX-Current-URL are used, so the redirect cannot leave the application’s origin.

MockMvc: the three behaviours
    @Test
    void expiredSessionGetRedirectsTheBrowserToTheCurrentPage() throws Exception {
        mvc.perform(get("/shelf?q=du").header("HX-Request", "true")
                        .header("HX-Current-URL", "http://localhost:8080/shelf?q=d"))
                .andExpect(status().isUnauthorized())
                .andExpect(header().string("HX-Redirect", "/shelf?q=d"));
    }

    @Test
    void plainBrowserRequestStillGoesToTheLoginPage() throws Exception {
        mvc.perform(get("/shelf"))
                .andExpect(status().isFound())
                .andExpect(header().string("Location", "/login"));
    }

    @Test
    void expiredSessionPostWithoutCsrfTokenIsRedirectedToo() throws Exception {
        mvc.perform(post("/shelf").header("HX-Request", "true")
                        .header("HX-Current-URL", "http://localhost:8080/shelf")
                        .param("title", "x").param("author", "y"))
                .andExpect(status().isForbidden())
                .andExpect(header().string("HX-Redirect", "/shelf"));
    }
curl output without a session cookie
GET  /shelf?q=du   (HX-Request, HX-Current-URL: .../shelf?q=d)   ->  401  HX-Redirect: /shelf?q=d
POST /shelf        (HX-Request, HX-Current-URL: .../shelf)       ->  403  HX-Redirect: /shelf
GET  /shelf        (no HX-Request)                               ->  302  Location: http://localhost:18931/login
GET  /shelf        (HX-Request, no HX-Current-URL)               ->  401  HX-Refresh: true

In Chromium, deleting a row after clearing the session cookie ended on /login, as the diagram shows.

The older alternative is HxRefreshHeaderAuthenticationEntryPoint: it answers with a plain 403 and HX-Refresh: true, so htmx reloads the current page and the user then meets the login redirect. It is registered for htmx requests only, with defaultAuthenticationEntryPointFor(new HxRefreshHeaderAuthenticationEntryPoint(), new RequestHeaderRequestMatcher("HX-Request")) in the README. It does not remove the failed htmx request from Spring Security’s saved request, does not cover a POST whose CSRF token expired with the session, and makes a boosted link reload the previous page. The README recommends the redirect-to-page pair. The unit test confirms its response:

MockMvc request objects: HxRefreshHeaderAuthenticationEntryPoint
    @Test
    void refreshEntryPointAnswers403WithHxRefresh() throws Exception {
        var response = new MockHttpServletResponse();
        new HxRefreshHeaderAuthenticationEntryPoint()
                .commence(new MockHttpServletRequest("GET", "/shelf"), response, new BadCredentialsException("expired"));
        assertThat(response.getStatus()).isEqualTo(403);
        assertThat(response.getHeader("HX-Refresh")).isEqualTo("true");
    }

The same library offers HxLocationRedirectAuthenticationSuccessHandler, HxLocationRedirectAuthenticationFailureHandler and HxLocationRedirectLogoutSuccessHandler for a login form that is itself submitted with htmx (so that a successful sign-in is not swapped into the form), and HxLocationBoostedRedirectStrategy to mark the follow-up request as boosted. They are described in the README section "Signing in with htmx"; general Spring Security configuration with Thymeleaf is on Spring Security Integration.

One more Spring Security detail from the run: the moment an application sets its own authenticationEntryPoint, Spring Security stops generating its default login page. The first run of this application, with formLogin(Customizer.withDefaults()) and the entry point above, answered GET /login with a 302 to itself. Declare loginPage("/login") and serve a template, as SecurityConfig does.

Error handling

htmx treats a response by its status code. The default configuration (htmx.config.responseHandling) is:

  • 204 No Content: nothing is swapped, not an error

  • 2xx and 3xx: swapped

  • 4xx and 5xx: not swapped, and htmx fires the htmx:responseError event

  • a connection failure fires htmx:sendError

So a validation error returned as 422 or a not-found fragment returned as 404 is silently dropped unless you configure htmx. There are three ways to handle it, and they combine.

Make htmx swap the codes you use. The htmx-config meta tag of page.html replaces the array so that 422 is swapped; every other 4xx or 5xx stays an error:

The meta tag in page.html (line 6)
<meta name="htmx-config" content='{"responseHandling":[{"code":"204","swap":false},{"code":"[23]..","swap":true},{"code":"422","swap":true},{"code":"[45]..","swap":false,"error":true},{"code":"...","swap":false}]}'>

Handle the rest in the browser. Listen to htmx:responseError and show a message. The listener writes only the numeric status with textContent, never response HTML:

static/js/shelf.js
document.addEventListener('htmx:responseError', (event) => {
  const box = document.getElementById('messages');
  if (box) {
    box.textContent = 'The server answered ' + event.detail.xhr.status + '. Please try again.';
  }
});

Handle the exception on the server. HtmxRequest and HtmxResponse can be arguments of an @ExceptionHandler, so one handler serves browser navigations and htmx requests differently. The handler below answers a duplicate title, a business error, with a 422 and a small notice fragment retargeted to the message area for htmx, and with an error page otherwise:

The exception handler
    @ExceptionHandler(DuplicateBookException.class)
    @ResponseStatus(HttpStatus.UNPROCESSABLE_CONTENT)
    String duplicate(DuplicateBookException ex, Model model,
                     HtmxRequest htmxRequest, HtmxResponse htmxResponse) {
        model.addAttribute("message", ex.getMessage());
        if (htmxRequest.isHtmxRequest()) {
            htmxResponse.setRetarget("#messages");
            htmxResponse.setReswap(HtmxReswap.innerHtml());
            return "shelf/page :: notice";
        }
        return "shelf/error";
    }
MockMvc: the duplicate case and the invalid form
    @Test
    void duplicateIsHandledByTheExceptionHandler() throws Exception {
        mvc.perform(post("/shelf").with(csrf()).header("HX-Request", "true")
                .param("title", "Walden").param("author", "Henry David Thoreau")).andExpect(status().isOk());
        mvc.perform(post("/shelf").with(csrf()).header("HX-Request", "true")
                        .param("title", "walden").param("author", "Henry David Thoreau"))
                .andExpect(status().isUnprocessableContent())
                .andExpect(header().string("HX-Retarget", "#messages"))
                .andExpect(header().string("HX-Reswap", "innerHTML"))
                .andExpect(content().string(containsString("already has")));
    }

    @Test
    void invalidFormIsRetargetedWith422() throws Exception {
        mvc.perform(post("/shelf").with(csrf()).header("HX-Request", "true")
                        .param("title", "").param("author", "x"))
                .andExpect(status().isUnprocessableContent())
                .andExpect(header().string("HX-Retarget", "#add-form"))
                .andExpect(header().string("HX-Reswap", "outerHTML"))
                .andExpect(content().string(containsString("id=\"add-form\"")))
                .andExpect(content().string(not(containsString("id=\"list\""))));
    }

@HxRetarget, @HxReswap and the other response annotations also work on an @ExceptionHandler method, as the README shows. Controller-advice error handling, error pages and flash messages in general are on Error Handling & Flash Messages.

The Chromium run exercised every branch with the page above:

Playwright results
search "du"                  list = [Dune by Frank Herbert], URL pushed: /shelf?q=du
add Hamlet                   count 3, form reset, event book-added (id=4) received by a document listener
add with an empty title      422 swapped by the config: the form is replaced and shows "must not be blank"
add "hamlet" again           422 retargeted to #messages: "The shelf already has "hamlet""
remove Hamlet (confirm)      count back to 2, event book-removed received
remove a stale row           404 -> htmx:responseError -> #messages: "The server answered 404. Please try again."
default response handling    the same invalid form is NOT swapped, #messages: "The server answered 422. ..."
session cookie removed       a click on Remove ends on /login

The Failed to load resource lines Chromium prints for 404 and 422 responses are its own network log, not errors in the application.

Migrating from the 3.x API

The book Modern frontends with htmx uses htmx-spring-boot 3.x with htmx 1.9 on Spring Boot 3.1. In that API a handler could return an HtmxResponse built with a builder; Spring Boot 4 needs the 5.x line, which has no such return type. The sources of 3.6.2, 4.0.3, 5.0.0 and 5.2.0 on Maven Central were compared to produce this table.

In 3.x (up to 3.6.2) Removed in In 5.x

return HtmxResponse.builder().view("fragments :: list").view("fragments :: count").build(); and HtmxResponse.addTemplate(...)

4.0 (deprecated since 3.6.0)

return FragmentsRendering.with("fragments :: list").fragment("fragments :: count").build(); or return a Collection<ModelAndView>

HtmxResponse.builder().trigger("evt")

4.0

HtmxResponse as a parameter with addTrigger("evt"), or @HxTrigger("evt")

.pushUrl(...), .retarget(...), .reswap(HxSwapType)

4.0

htmxResponse.setPushUrl(...), setRetarget(...), setReswap(HtmxReswap...), or @HxPushUrl, @HxRetarget, @HxReswap

.redirect("/x"), @HxRedirect

4.0 (builder), 5.0.0 (annotation)

return "redirect:htmx:/x"; or new HtmxRedirectView("/x")

.refresh(), @HxRefresh

4.0 (builder), 5.0.0 (annotation)

return "refresh:htmx"; or new HtmxRefreshView()

.location(...), @HxLocation

4.0 (builder), 5.0.0 (annotation)

return "redirect:htmx:location:/x"; or HtmxLocationRedirectView with its setters

HtmxView (a view made of several templates)

5.0.0

FragmentsRendering

@HxTriggerLifecycle (3.x annotation, deprecated)

4.0

@HxTriggerAfterSwap and @HxTriggerAfterSettle

Jackson 2 (com.fasterxml.jackson) on the classpath of the library

5.0.0

Jackson 3 (tools.jackson); the dialect constructor takes a tools.jackson.databind.ObjectMapper

Spring Boot 3 as the target of the library

5.0.0

Spring Boot 4 only; Boot 3.4 and 3.5 stay on 4.0.3

Besides the library, two things in the htmx 1.9 era code change with htmx 2: DELETE parameters move into the URL (see CSRF with htmx), and the extensions (SSE, WebSockets, response targets) are separate packages; both are listed on htmx Fundamentals. The HxRequest condition and the hx: dialect themselves did not change shape between the major lines, so templates usually need no edit.

Testing with MockMvc

Everything above was asserted with MockMvc, which exercises the request mapping conditions, the argument resolvers and the response advice. A test sets HX-Request and the other request headers by hand; the Security tests add csrf() or csrf().asHeader(), and the unauthenticated tests omit the user. Because the jar contributes HtmxMvcAutoConfiguration to the MockMvc slice, a @WebMvcTest sees the same behaviour; the only application beans it needs are the controller’s collaborators:

A @WebMvcTest slice
@WebMvcTest(ShelfController.class)
@Import(ShelfSliceTest.Config.class)
@WithMockUser
class ShelfSliceTest {

    @TestConfiguration
    static class Config {
        @Bean
        ShelfService shelfService() {
            ShelfService service = Mockito.mock(ShelfService.class);
            Mockito.when(service.find("")).thenReturn(List.of(new ShelfBook(1, "Dune", "Frank Herbert")));
            return service;
        }
    }

    @Autowired
    MockMvc mvc;

    @Test
    void htmxRequestSelectsTheFragmentHandlerInTheWebMvcSlice() throws Exception {
        mvc.perform(get("/shelf").header("HX-Request", "true"))
                .andExpect(status().isOk())
                .andExpect(view().name("shelf/page :: list"))
                .andExpect(header().string("Vary", "HX-Request"));
        mvc.perform(get("/shelf"))
                .andExpect(view().name("shelf/page"));
    }
}
MockMvc: the dialect-free assertions in the Boot test
    @Test
    @WithMockUser
    void csrfTokenInTheHeaderIsAccepted() throws Exception {
        mvc.perform(post("/shelf").with(csrf().asHeader()).header("HX-Request", "true")
                        .param("title", "Persuasion").param("author", "Jane Austen"))
                .andExpect(status().isOk())
                .andExpect(content().string(containsString("Persuasion")));
    }

The full suite of the scratch application ran with ./mvnw test: 29 tests, 0 failures (12 of them in the htmx test class, 6 in the security test, 1 in the slice test). HtmxRequest.builder() and HtmxRequest.empty() build instances for unit tests of code that takes an HtmxRequest directly.

Pitfalls

Symptom Cause Fix

A plain form post or curl to a handler returns 404

The only handler for that URL and method has @HxRequest, so requests without HX-Request do not match

Add a handler without @HxRequest for the same URL (the progressive-enhancement twin) or accept that the URL is htmx-only

A boosted link shows a bare fragment instead of the page

@HxRequest matches boosted requests by default (boosted = true) and the fragment handler won

Use @HxRequest(boosted = false) on the fragment handler so boosted requests reach the page handler

The back button or a history restore shows a fragment without layout

A cache stored the fragment under the page URL, or a history-restore request reached the fragment handler

Keep historyRestoreRequest = false (the default), keep Spring Security’s no-store headers, and add Vary: HX-Request to the page response if you cache it

An invalid form seems to do nothing

htmx does not swap 4xx and 5xx responses; the 422 was dropped

Configure htmx.config.responseHandling (the htmx-config meta tag) for 422, or listen to htmx:responseError

403 on every hx-post or hx-delete, but forms work

A static hx-post has no CSRF token; only forms with th:action and the hx: mutating attributes add one

Use hx:post, hx:put, hx:patch and hx:delete, or send the header with htmx:configRequest and meta tags

_csrf=... appears in the URL of a DELETE in the access log

hx-include of a hidden CSRF input on hx-delete: htmx 2 sends DELETE parameters in the URL

Send the token as the X-CSRF-TOKEN header, never as a parameter of a DELETE or GET

Could not parse as expression: "#list" when rendering

hx:target="#list": Thymeleaf treats # as the start of an expression or message

Write hx-target="#list" or hx:target="${'#list'}"

After a POST the whole login or list page appears inside the target element

The handler returned redirect:/path; the 302 was followed by the XHR and its page swapped into the target

Return redirect:htmx:/path (full navigation) or redirect:htmx:location:/path, and keep a normal twin for non-htmx requests

The login page vanishes and /login redirects to itself

A custom authenticationEntryPoint turns off Spring Security’s generated login page

Declare loginPage("/login") and serve your own template

An expired session shows the login page inside a list, or buttons stop reacting

The default entry point redirects the XHR to the login page; the CSRF failure of a POST is a silent 403

Use HxRedirectToPageAuthenticationEntryPoint with HxRedirectToPageAccessDeniedHandler (or, older, HxRefreshHeaderAuthenticationEntryPoint for htmx requests)

cannot find symbol: builder() or addTemplate after upgrading

The 3.x HtmxResponse API was removed in 4.0; @HxRedirect, @HxLocation, @HxRefresh and HtmxView in 5.0.0

Follow the migration table: HtmxResponse as a parameter, FragmentsRendering, the redirect:htmx: prefixes

An HX-Trigger event with a detail never reaches a listener, or JSON.stringify(event.detail) throws

The detail object contains htmx’s DOM reference elt, which is circular for JSON.stringify

Read the fields you sent (event.detail.id), do not serialise the whole detail

HX-Trigger set on a handler that redirects with a 302 does nothing

htmx does not process response headers on 3xx responses

Answer 200 with HX-Redirect, HX-Location or HX-Refresh (the htmx views do)

A request mapping with target = "#count" never matches

The HX-Target header holds the id without #

Use target = "count"

The library does nothing in a WebFlux application

The auto-configurations are conditional on a servlet web application

Use plain hx-* attributes and read HX-Request yourself with @RequestHeader