Spring Boot 4 + Thymeleaf + htmx: Server-Side CRUD UI in 2026
Build a contacts screen with Spring Boot 4.1.2, Thymeleaf 3.1.5 and htmx 2.0.11 and no JS framework: one URL that returns a full page or a fragment, 422 validation re-render, pagination, inline edit and delete, with MockMvc tests and a headless Chromium check, and the htmx 2 status-code traps that bit during the build.
A list of records, a search box, five rows per page, an Edit button that turns a row into inputs, and a form that shows “Name is required” next to the field. That is most of what internal tools, admin screens and small business apps are made of, and in 2026 you can build all of it with Spring Boot and Thymeleaf, with no JavaScript framework, no JSON API and no build step.
The missing piece used to be partial updates: without a framework, every click reloaded the whole page. htmx fills exactly that gap. It is a small JavaScript file that lets an HTML element say “when clicked, call this URL and put the HTML that comes back here“. Your controllers keep returning HTML, and Thymeleaf keeps rendering it. This post builds a contacts screen that way, from the layout to the tests, and shows the real HTML the server sends at each step.
Versions this was built and run against (checked 2026-10-28). Spring Boot 4.1.2, the newest GA on Maven Central (4.2.0-RC1 also exists, but it is a release candidate); Thymeleaf 3.1.5.RELEASE, the version Boot manages; Hibernate 7.4.13.Final; htmx 2.0.11, npm’s latest tag (published 2026-09-22), served from the WebJar org.webjars.npm:htmx.org:2.0.11; JDK 25; headless Chromium driven by Playwright 1.64.1 for the browser checks. htmx 4.0.0 was released on 2026-08-28 but is deliberately not what this post uses; the last section explains why.
Companion code: the thymeleaf-htmx module of spring-boot-demo. Every console block below is copied from a file in its output/ folder. The MockMvc transcripts are written by the tests themselves, so a number quoted here is an assertion that fails the build if it stops being true.
The idea: send a piece of a page instead of the whole page
A normal web request returns a complete HTML document and the browser throws away the old page. With htmx, an element such as a search box or a Delete button carries attributes like hx-get="/contacts?page=2" and hx-target="#contact-table". When the user acts, htmx makes that request in the background, takes the HTML in the response and swaps it into the target element. Nothing else on the page changes, and no JSON is involved.
That gives the server one new job. The same URL must sometimes return the full page (first visit, a bookmark, a browser with JavaScript off) and sometimes only a fragment. htmx marks its own requests with an HX-Request: true header, and that header is the whole mechanism.
The diagram shows the only fork in the design. Everything after this is a variation on it: which fragment to return, and what to return when the input is invalid.
Setup: the starters, htmx as a WebJar, and a layout
Spring Boot 4 split its starters into smaller modules, so the names are worth checking rather than remembering. These exist on Maven Central at 4.1.2: spring-boot-starter-webmvc (Spring MVC; the Thymeleaf starter does not bring it in), spring-boot-starter-thymeleaf, spring-boot-starter-validation, spring-boot-starter-data-jpa, and for tests spring-boot-starter-webmvc-test and spring-boot-starter-thymeleaf-test. The POM of the last one depends only on spring-boot-starter-thymeleaf and spring-boot-starter-test and its jar holds no classes, so it is a convenience that pulls Thymeleaf into the test classpath rather than something you cannot work without. @AutoConfigureMockMvc lives in the webmvc test module, in the package org.springframework.boot.webmvc.test.autoconfigure; my first draft imported the old boot.test.autoconfigure.web.servlet package and failed to compile.
For htmx itself there are two honest options: copy htmx.min.js into src/main/resources/static/ (“vendoring”), or depend on a WebJar. I used the WebJar. The version then lives in pom.xml, where mvn versions:display-dependency-updates or Dependabot can see it, and with webjars-locator-lite the template can ask for the file without writing the version. Vendoring is just as valid if you want no build-time dependency on a third-party jar; the cost is a file that silently ages.
<!-- htmx as a WebJar: the jar is pinned in this pom, served from /webjars/**. -->
<dependency>
<groupId>org.webjars.npm</groupId>
<artifactId>htmx.org</artifactId>
<version>${htmx.version}</version>
</dependency>
<!-- lets templates ask for /webjars/htmx.org/dist/htmx.min.js without the version -->
<dependency>
<groupId>org.webjars</groupId>
<artifactId>webjars-locator-lite</artifactId>
</dependency>
That block is from pom.xml. The layout is a Thymeleaf fragment with two parameters, the page title and the page content, so each page supplies only its own part:
This is layout/base.html. A page opts in with th:replace="~{layout/base :: layout(~{::title}, ~{::main})}" on its root element. I used this plain fragment pattern instead of the third-party Layout Dialect (4.0.1 is on Maven Central) because it needs no extra dependency. The htmx-config meta tag looks odd now; the forms section explains it.
That is from output/03-htmx-webjar.txt: the template asks for /webjars/htmx.org/dist/htmx.min.js and the rendered page carries the pinned version. A real Tomcat serves the version-less path with 200 text/javascript (output/10-live-curl.txt).
Going deeper: WebJars, caching and the version in the path
The locator rewrites the version-less path to the versioned one when the page is rendered. That also lets browsers cache the file for a long time safely: a new htmx version means a new URL. The script tag uses defer, so it does not block the first paint. In Chromium, htmx.version reported 2.0.11 (output/11-browser-check.txt).
Here is the decision in code. A tiny helper reads the headers, and the controller returns a Thymeleaf fragment selector (template :: fragmentName) as the view name when it should send a fragment.
From output/10-live-curl.txt. The first response starts with a doctype; the second starts directly with the <div id="contact-table"> element. The MockMvc version of the comparison, which also asserts that the fragment contains no <html, <head, <title> or layout header, is output/01-full-page-vs-fragment.txt.
Add Vary: HX-Request, or the Back button can show a fragment. One URL with two representations breaks HTTP caching unless caches are told. If a browser stores the fragment for /contacts and later navigates to that URL normally, it may display a bare table with no layout. The module adds the header in a one-method filter (VaryHtmxFilter.java), and both Vary lines are visible on every response above. I verified the header is sent; I did not build a cache that misbehaves without it.
A second header needs handling. When the user presses Back and htmx has no stored snapshot of the earlier page, it asks the server again and swaps the answer into the whole <body>. That request carries bothHX-Request: true and HX-History-Restore-Request: true, and it needs a full page, which is why wantsFragment returns false for it. In headless Chromium I set htmx.config.historyCacheSize = 0 to force the miss and pressed Back:
Back request: GET /contacts?q= HX-Request=true HX-History-Restore-Request=true
after Back: h1="Contacts", pager="Page 1 of 5 (23 contacts)"
From output/11-browser-check.txt. The request carried both headers and the page that came back had its heading and pager. The MockMvc version is output/02. I did not run the opposite experiment, removing the check to watch it fail.
Going deeper: Other ways to return fragments
Returning "contacts/list :: table" is the oldest and plainest way, and it is what this module uses. Spring Framework also has a FragmentsRendering type for returning several fragments from one handler, which is useful with htmx out-of-band swaps. I did not exercise it here, so treat it as a pointer, not a verified recipe.
Search and pagination that still work without JavaScript
The list is a Spring Data Page: five rows per page, sorted by name. The seed data is 23 contacts, so the pager reads “Page 1 of 5” and the last page holds three rows. The whole table and pager live inside one element marked as the fragment:
That block is from list.html. Three details carry the design.
The pager links have a real hrefand an hx-get. With JavaScript they swap in the table; without it they are ordinary links that load the full page. The search box falls back to a plain form with a <noscript> button.
hx-push-url="true" updates the address bar. In Chromium, typing “kamat” changed the URL to /contacts?q=kamat while the same document stayed alive (a marker I set on window survived), and Next changed it to /contacts?page=1.
The URLs are built in the controller with UriComponentsBuilder. In my first version the template built them, and an empty search term rendered as ?q=&page=0 in every link.
== 1. load, then search without a page reload
htmx loaded: object 2.0.11
rows on first page: 5
after typing "kamat": ["Sneha Kamat"]
address bar: /contacts?q=kamat
elements with id contact-table: 1
document survived (no reload): same-document
That is the start of output/11-browser-check.txt. Now the page that does not exist. Asking for page=99 should not render an empty table:
# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' '/contacts?page=99'
HTTP 200
<span>Page 5 of 5 (23 contacts)</span>
</nav>
Those are the request line and, from further down the same response body, the pager. The file is output/04-pagination-and-search.txt. The controller clamps to the last page, and that clamp exists because a test failed. My first version compared page.getNumber() with the requested index to detect an overshoot. The failing test showed the response “Page 100 of 5”: Page.getNumber() echoes the requested index even when it is past the end, so the comparison never fired. The fix compares against getTotalPages() (visible in the controller excerpt above). This matters for delete: remove the last row of the last page and the page you are on stops existing.
Default swap is innerHTML, and that nests your fragment inside itself. The search input targets #contact-table, and the server answers with a whole <div id="contact-table">. Without hx-swap="outerHTML" on the input, htmx puts that div inside the old one. It looks fine. I only noticed because one delete after a search triggered two refresh requests to /contacts?page=0 instead of one, a pattern consistent with a duplicate element listening for the same event (I did not inspect the listeners). With the attribute added, the browser check asserts there is exactly one element with that id after searching, and that one delete causes exactly one refresh (both lines from output/11-browser-check.txt):
elements with id contact-table: 1
document survived (no reload): same-document
requests caused by one delete: ["DELETE /contacts/1 HX-Request=true HX-History-Restore-Request=-","GET /contacts?page=0 HX-Request=true HX-History-Restore-Request=-"]
Going deeper: Why the table refreshes itself after a delete
The table element declares hx-trigger="contactsChanged from:body" and its own hx-get (its current page and search). The delete endpoint answers with an HX-Trigger: contactsChanged response header, htmx fires that event on the body, and the table re-fetches whatever page it is showing. That keeps the counts, the pager and the page contents honest without the server needing to know which page the user was on. The scenario of deleting all three rows of the last page is asserted in output/09-delete-last-page-clamps.txt: the table asks for page index 4 of what is now a four-page list and gets “Page 4 of 4 (20 contacts)”.
The create form is an ordinary Thymeleaf form bound to a bean, with Bean Validation annotations on the bean. If validation fails, the controller sets status 422 and returns the form again, now carrying the errors and what the user typed.
The create handler is in ContactController.java:
@PostMapping
Object create(@Valid @ModelAttribute ContactForm contactForm, BindingResult errors,
HttpServletRequest request, HttpServletResponse response) {
if (repo.existsByEmailIgnoreCase(contactForm.getEmail() == null ? "" : contactForm.getEmail())) {
errors.rejectValue("email", "duplicate", "That email is already in the list");
}
if (errors.hasErrors()) {
response.setStatus(HttpStatus.UNPROCESSABLE_ENTITY.value());
return Htmx.wantsFragment(request) ? "contacts/new :: form" : "contacts/new";
}
repo.save(newContact(contactForm));
if (Htmx.wantsFragment(request)) {
// htmx follows HX-Redirect with a full page navigation
return ResponseEntity.ok().header("HX-Redirect", "/contacts").build();
}
return "redirect:/contacts"; // classic post-redirect-get for plain browsers
}
These are from ContactController.java and new.html. The form works two ways. With JavaScript, hx-post swaps the returned form into place. Without it, the browser posts normally and gets the full page back, and on success a classic redirect. Here is what comes back for a blank name and a bad email:
From output/05-create-validation-errors.txt, which also contains the same failure without the htmx header, where the body is the full page. The user’s input is preserved (value="not-an-email"), the invalid fields get a CSS class, and the rows were not saved (the test asserts the count is still 23). A duplicate email is reported as a field error on email in the same way.
htmx 2 does not swap a 422 response unless you tell it to. The default responseHandling in the htmx 2.0.11 source I unzipped from the WebJar is: 204 does not swap, 2xx and 3xx swap, 4xx and 5xx do not swap and raise an error. A 422 therefore lands in the “error, do not swap” bucket and your carefully rendered error messages never reach the page. The layout fixes it with one meta tag that adds a rule for 422 before the generic 4xx rule. To confirm this was not just something I read, the browser check removes that tag from the page and saves an invalid inline edit:
== 6. the two status-code traps, observed
DELETE answered 204: rows before=5, after=5 (row not removed)
htmx-config meta present: 0
Save answered 422 without the meta tag: errors visible = 0
The alternative is to return 200 with the error markup, which needs no configuration but makes the status code lie to logs, tests and proxies. I prefer 422 plus the config tag.
On success, an htmx request gets an empty 200 with an HX-Redirect: /contacts header, which makes htmx navigate; a plain browser gets a redirect.
== 5. create form: 422 re-render, then success
errors shown in place: ["Name is required","Email must look like name@host"]
still on: /contacts/new, typed email kept: x
HX-Redirect landed on: /contacts
Going deeper: Binding, records and CSRF
The form object is a plain getter/setter bean, which is the shape the Thymeleaf-Spring integration documents for th:field. Whether a Java record would also bind and render here was not tested. This module has no Spring Security dependency, so CSRF was not exercised: with Security on, state-changing htmx requests (POST, PUT, DELETE) need the token sent as a header, and I have not run that setup, so check the htmx and Spring Security documentation rather than this post for it.
Inline editing is three small endpoints returning table rows. The Edit button asks for the row in edit mode and replaces itself with it; Save sends the row’s inputs with hx-include="closest tr" (a table row cannot contain a form element, so the inputs are collected by the button); Cancel asks for the read-only row again. A failed save returns the edit row with errors, which is the same fragment filled differently.
The diagram is the whole feature: three renderings of a row, with the HTTP status separating success from failure. The edit row and the error row are the same Thymeleaf fragment:
== 3. inline edit with a validation error
row still editing, errors: ["Name is required","Email must look like name@host"]
saved, row is read-only again: ["Aarav K.","Bhavna Joshi","Chinmay Desai","Divya Nair","Eshaan Patil"]
Delete: answer 200, not 204
REST habit says a successful DELETE returns 204 No Content. With htmx 2 that breaks the page: the default rules above say a 204 is not swapped, so the row stays on screen even though the record is gone. The endpoint answers 200 with an empty body, which htmx swaps in as “nothing”, removing the row.
# request (curl notation, sent through MockMvc): curl -X DELETE -H 'HX-Request: true' /contacts/1
HTTP 200
Vary: HX-Request
Vary: HX-History-Restore-Request
HX-Trigger: contactsChanged
---- body (0 chars) ----
From output/08-delete.txt. To check the claim in a browser, the check script intercepts the DELETE and answers 204 itself:
== 6. the two status-code traps, observed
DELETE answered 204: rows before=5, after=5 (row not removed)
The row stayed. The status codes this module uses, against the htmx 2.0.11 defaults read from the WebJar source:
Status
htmx 2.0.11 default
Used here for
200
swaps
delete (empty body), successful edit, create via HX-Redirect
204
does not swap
nothing (see above)
422
does not swap, flagged as error
validation failures; swapping enabled by the htmx-config meta tag
404
does not swap, flagged as error
unknown id on edit or delete
The delete button also carries hx-confirm, which uses the browser’s confirm dialog (Delete Aarav K.? in the transcript above). There is no non-JavaScript fallback for delete in this module; a plain form posting to a delete route would be the way to add one.
Testing fragments with MockMvc
Because the server returns HTML, the contract is testable without a browser: same URL, with and without the header, assert on the markup. The tests use @SpringBootTest with @AutoConfigureMockMvc and @Transactional so each test rolls back its deletes.
void fullPageVersusFragment() throws Exception {
MvcResult full = mvc.perform(get("/contacts")).andExpect(status().isOk()).andReturn();
MvcResult frag = mvc.perform(get("/contacts").header("HX-Request", "true"))
.andExpect(status().isOk()).andReturn();
String f = full.getResponse().getContentAsString();
String h = frag.getResponse().getContentAsString();
assertThat(f).contains("<!DOCTYPE html>", "<title>Contacts</title>", "<header>", "<table>");
assertThat(h).doesNotContain("<!DOCTYPE", "<html", "<head", "<title>", "<header>");
assertThat(h.strip()).startsWith("<div id=\"contact-table\"").endsWith("</div>");
assertThat(h).contains("<table>", "Page 1 of 5 (23 contacts)");
assertThat(h.length()).isLessThan(f.length());
// a cache must key on the header, or the back button shows a fragment
assertThat(full.getResponse().getHeaders("Vary")).contains("HX-Request", "HX-History-Restore-Request");
assertThat(frag.getResponse().getHeaders("Vary")).contains("HX-Request", "HX-History-Restore-Request");
new Transcript("01-full-page-vs-fragment.txt")
.exchange("curl /contacts", full, HEADERS)
.exchange("curl -H 'HX-Request: true' /contacts", frag, HEADERS)
.write();
That is from ContactUiTest.java; the helper that writes the transcripts is Transcript.java. There are 10 tests, covering the full page versus fragment, history restore, the WebJar, pagination and search, invalid and duplicate creates, valid creates, the inline edit cycle, delete, and the last-page clamp.
Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.50 s -- in com.ankurm.thymeleafhtmx.ContactUiTest
Assertions on markup are string checks, which is crude but fast. They do not prove htmx behaves as expected in a browser, which is why the repository also has a small Playwright script (browser-check/check.mjs) and why several claims above are tagged with what Chromium did. It needs Node and a one-time npx playwright install chromium, so it is optional; scripts/run-all.sh runs everything and regenerates all of output/.
Going deeper: MockMvc versus a real browser
MockMvc exercises Spring MVC, validation and Thymeleaf rendering, but it never executes htmx, so it cannot see swap rules, history handling or event wiring. Every bug that mattered in this module’s browser-only layer (the nested swap, 204 and 422 handling) is of that kind. The practical split I would keep: MockMvc for what the server sends, a handful of browser scenarios for what the page does with it.
Should you build it this way, and what about htmx 4?
Use this stack when the screens are forms, tables and lists and your team already writes Spring. One language, one deploy, no API contract to version, and a page that works when JavaScript fails. Do not use it for interfaces with heavy client-side state (drawing, drag-and-drop boards, offline use): you will end up writing the JavaScript anyway, and the round trip per interaction becomes the product. Also note what this module does not cover: authentication and CSRF, file uploads, and concurrency (two people editing one row; there is no version column).
htmx 4. htmx 4.0.0 shipped on 2026-08-28. According to the release announcement, 2.x keeps the npm latest tag until some point in early 2027 and is “supported indefinitely”; 4.x moves from XMLHttpRequest to fetch(), makes attribute inheritance opt-in (:inherited), and stops snapshotting pages in localStorage. This module relies on inheritance (the pager links inherit hx-swap from the table div) and on the 2.x history behaviour, so I did not run it on htmx 4, and the 422 and 204 findings above are for 2.0.11 only. Re-check them before upgrading.
No Comments yet!