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 |
|---|---|---|
|
|
The request mapping annotation |
|
|
Spring Security helpers for htmx requests: |
|
|
The Thymeleaf dialect with the prefix |
Source: htmx-spring-boot 5.2.0 README
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 |
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 |
5.0.0 |
2025-11-27 |
4.0.0 |
17 |
htmx 2.x. First line for Spring Boot 4 (Jackson 3, package |
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 |
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.
<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):
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"
integrity="sha384-2OatzQy1H+Zd/IIrjr1TcuDGqLXeHhbooAyJY1KdQMKnr4LZ22k31GBLdYKHmVjg"
crossorigin="anonymous"></script>
Source: htmx — Installing
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.
<!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:
// 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 -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'
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>
Source: htmx — Caching
Three behaviours of the condition were checked with MockMvc and are worth knowing:
-
@HxRequesthas an attributeboosted, which defaults totrue: boosted requests (hx-boost, headerHX-Boosted) are also matched. A boosted link swaps the wholebody, 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
historyRestoreRequestdefaults tofalse: when htmx restores a page from history after a cache miss it sendsHX-History-Restore-Requesttogether withHX-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(classHtmxHandlerInterceptor) to the response of every request that has theHX-Requestheader. The full-page response of a request without that header gets noVaryheader from the library, as the output above and thecurlrun show. In this application Spring Security’s defaultCache-Control: no-cache, no-storeheaders keep both representations out of caches; if you remove them or serve the page through a CDN, addVary: HX-Requestto 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 |
|---|---|---|
|
|
The element that triggered the request has this id or this name; use |
|
|
Only requests triggered by the element with this id |
|
|
Only requests triggered by the element with this |
|
|
Only requests whose target element has this id (the header holds the id without the leading |
|
|
|
|
|
|
Source: HxRequest Javadoc
// 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";
}
@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 |
|---|---|---|
|
|
|
|
|
|
|
|
The URL of the page in the browser, or |
|
|
|
|
|
The answer the user typed into an |
|
|
Id of the target element, or |
|
|
Id of the triggered element, or |
|
|
Name of the triggered element, or |
Source: HtmxRequest Javadoc
@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());
}
@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-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.
// 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";
}
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.
// 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";
}
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>
@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, sodocument.addEventListener('book-added', e => ...)receives it withe.detail.id(verified in Chromium; the detail object holds the parsed JSON plus htmx’s owneltreference, andJSON.stringifyon 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
@ResponseBodyandResponseEntityreturns, by aResponseBodyAdvice. They are available in@ExceptionHandlermethods 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-Triggerand also send the user elsewhere must answer 200, which is what the views below do. -
The
HtmxResponsetype is not a return value in 5.x: a method declares it as a parameter and fills it.
Source: htmx — Response headers
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 |
|---|---|---|---|
|
|
|
A full navigation to the URL, as if the user had followed a link |
|
|
|
An ajax navigation: htmx fetches the URL and swaps |
|
|
|
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.
// 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";
}
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.
@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.
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>
Source: htmx — hx-swap-oob
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.
@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();
}
@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());
}
Source: htmx — Out of Band Swaps
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.
Attributes, link expressions and maps
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.
<!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>
<a hx-get="/shelf/42" hx-target="#main">Details</a>
<button hx-post="/shelf/42/borrow" hx-vals="{"bookId":42,"source":"shelf"}" hx-headers="{"X-CSRF-TOKEN":"<token>"}">Borrow</button>
<button hx-delete="/shelf/42" hx-headers='{"X-Trace":"t1","X-CSRF-TOKEN":"<token>"}'>Remove</button>
<button hx-get="/shelf" hx-target="#list" hx-swap="outerHTML">Reload</button>
<button hx-vals="{"title":"Dune \"Messiah\" <b>"}" hx-post="/shelf" hx-headers="{"X-CSRF-TOKEN":"<token>"}">Escaped</button>
<p>false</p>
What the output demonstrates, and what the sources of the processor confirm:
-
A
java.util.Mapresult is converted to JSON and entries with anullkey or value are dropped.hx:vals="${ {bookId: id, source: 'shelf'} }"is a Thymeleaf inline map (SpEL), and the JSON is whathx-valsrequires. Any other result is written withtoString(). -
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 anhx-valsorhx-onstring 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 withCould not parse as expression: "#list"because Thymeleaf reads#as the start of an expression object or message. Use the statichx-target="#list", or quote the selector in an expression as shown. -
The
#htmxRequestexpression 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).
@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=\"{"bookId":42,"source":"shelf"}\" hx-headers=\"{"X-CSRF-TOKEN"")
.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:
if (errors.hasErrors()) {
htmx.setRetarget("#add-form");
htmx.setReswap(HtmxReswap.outerHtml());
return FragmentsRendering.with("shelf/page :: add-form")
.status(HttpStatus.UNPROCESSABLE_CONTENT)
.build();
}
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 |
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 |
|
The dialect writes the header into |
Nothing else to write; the token is repeated per element (see above) |
Meta tags and |
The token and the header name are rendered once into |
One token per page; works with static |
|
The htmx documentation shows |
|
Source: htmx — CSRF Prevention
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:
<!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>
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;
}
});
Source: htmx — htmx:configRequest
The three buttons issue a DELETE to a probe endpoint. Chromium (driven by Playwright) recorded the requests the browser sent after clicking each one:
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.
The library has an entry point and an access denied handler for this, new in 5.2.0:
@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.
@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"));
}
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:
@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:responseErrorevent -
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:
<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:
document.addEventListener('htmx:responseError', (event) => {
const box = document.getElementById('messages');
if (box) {
box.textContent = 'The server answered ' + event.detail.xhr.status + '. Please try again.';
}
});
Source: htmx — htmx:responseError
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:
@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";
}
@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:
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 |
|---|---|---|
|
4.0 (deprecated since 3.6.0) |
|
|
4.0 |
|
|
4.0 |
|
|
4.0 (builder), 5.0.0 (annotation) |
|
|
4.0 (builder), 5.0.0 (annotation) |
|
|
4.0 (builder), 5.0.0 (annotation) |
|
|
5.0.0 |
|
|
4.0 |
|
Jackson 2 ( |
5.0.0 |
Jackson 3 ( |
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 |
Source: htmx-spring-boot — Releases
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:
@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"));
}
}
@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 |
The only handler for that URL and method has |
Add a handler without |
A boosted link shows a bare fragment instead of the page |
|
Use |
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 |
An invalid form seems to do nothing |
htmx does not swap 4xx and 5xx responses; the 422 was dropped |
Configure |
403 on every |
A static |
Use |
|
|
Send the token as the |
|
|
Write |
After a POST the whole login or list page appears inside the target element |
The handler returned |
Return |
The login page vanishes and |
A custom |
Declare |
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 |
|
The 3.x |
Follow the migration table: |
An |
The detail object contains htmx’s DOM reference |
Read the fields you sent ( |
|
htmx does not process response headers on 3xx responses |
Answer 200 with |
A request mapping with |
The |
Use |
The library does nothing in a WebFlux application |
The auto-configurations are conditional on a servlet web application |
Use plain |
References
-
htmx-spring-boot 5.2.0 README (and the 5.x branch for htmx 2)
-
htmx documentation (Caching, Response handling, CSRF prevention, Out of band swaps)
-
HX-Location, HX-Redirect and HX-Trigger response headers
-
hx-headers, hx-vals and hx-swap-oob
-
htmx 4 documentation (preview; the target of htmx-spring-boot 6.0.0)