HTMX with Spring Boot and Thymeleaf: A Production-Grade Guide Beyond the To-Do App

Spring Boot HTMX Tutorial

Stop wrestling with React build pipelines. Your Spring Boot backend can deliver SPA-like responsiveness with 14 KB of JavaScript and zero npm dependencies.



What you’ll learn: Fragment rendering with @HxRequest, Out-of-Band swaps with FragmentsRendering, CSRF token integration with Spring Security, error handling with the response-targets extension, the htmx.process() fix for dynamic content, and production patterns for loading states, debouncing, and hx-boost. A complete working Contact Manager example is available on GitHub.

The frontend ecosystem has spent a decade convincing backend developers that they need a separate JavaScript application to build anything interactive. For a surprisingly large category of business applications — admin dashboards, internal tools, CRUD-heavy line-of-business apps, content management systems — that was never true.

HTMX is a 14 KB library that lets you make AJAX requests, trigger CSS transitions, and swap DOM elements using nothing but HTML attributes. No virtual DOM. No state management library. No build step. Your Spring Boot controllers return HTML fragments instead of JSON, and your Thymeleaf templates do the rendering they were always designed to do.

This is not a to-do app tutorial. If you have read the introductory “click a button, swap a div” blog posts and are wondering how to actually build something with HTMX in production, this guide is for you. We will cover fragment rendering patterns, Out-of-Band swaps for updating multiple page regions simultaneously, Spring Security integration (including the CSRF token problem that trips up everyone), error handling, the htmx.process() gotcha that breaks dynamically injected content, and the patterns you need for loading indicators, search debouncing, and history management.

By the end, you will have a mental model for building server-driven interactive applications that deploy as a single Spring Boot JAR, require no Node.js toolchain, and are dramatically simpler to maintain than their SPA equivalents.


Why the “Modern Monolith” Is Not a Step Backwards

Before we get into code, it is worth understanding the architectural current that is making HTMX relevant right now.

The software industry is in the middle of a quiet correction. After years of defaulting to microservices and single-page applications for projects of every size, experienced teams are admitting that the distributed complexity was often a solution to an organizational problem (team scaling), not a technical one. Spring Modulith, announced by the Spring team as a first-class project, exists precisely to support teams building well-structured monoliths with clear internal module boundaries.

The frontend side of this correction is equally significant. The cost of maintaining a React or Angular application alongside a Spring Boot API is substantial: separate build pipelines, separate deployment artifacts, duplicated validation logic, API versioning overhead, and the constant churn of the JavaScript ecosystem. For applications where the primary interaction model is “user fills out a form, submits it, sees updated data,” this cost is hard to justify.

HTMX fits this correction perfectly. It lets you keep the server as the single source of truth, use your existing Thymeleaf templates for rendering, and add interactivity incrementally — without committing to a framework that will demand a rewrite in three years.

This does not mean HTMX replaces React for everything. Real-time collaborative editing, complex drag-and-drop interfaces, offline-first applications, and highly interactive data visualization tools still benefit from a dedicated frontend framework. But for the 80% of enterprise applications that are fundamentally about displaying and manipulating data, HTMX paired with Spring Boot is a compelling and dramatically simpler architecture.


Project Setup: Spring Boot, Thymeleaf, and HTMX Dependencies

We will use Spring Boot 4.x with Thymeleaf and the htmx-spring-boot integration library maintained by Wim Deblauwe. This library provides Spring-native annotations, argument resolvers, and a Thymeleaf dialect that eliminate the boilerplate of working with HTMX’s HTTP headers manually.

Prerequisites: Java 17+, Spring Boot 4.x, and basic familiarity with Thymeleaf. A complete working example is on GitHub: tucanoo/spring-boot-htmx-tutorial.

Dependencies

Add the following to your build.gradle (or the equivalent Maven coordinates):

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-security'
    // The HTMX integration library
    implementation 'io.github.wimdeblauwe:htmx-spring-boot-thymeleaf:5.0.0'
}

The htmx-spring-boot-thymeleaf artifact includes the core htmx-spring-boot library and adds a Thymeleaf dialect that provides the hx: attribute namespace. This lets you write hx:get="@{/users}" instead of th:attr="hx-get=@{/users}", which is both more readable and less error-prone.

Include HTMX itself via a CDN or a WebJar in your layout template:

<script src="https://unpkg.com/htmx.org@2.0.8"
        crossorigin="anonymous"></script>

Contact Manager application built with Spring Boot and HTMX showing contact table with search input, Total 5 badge, Add New Contact form, and htmx.process demo section

 


How HTMX Works with Spring Boot: Controllers Return HTML Fragments

The single most important shift when working with HTMX is understanding that your controllers now serve two roles: they handle full-page requests (initial load, browser navigation) and they handle fragment requests (HTMX-triggered partial updates). The same endpoint can serve both.

The @HxRequest annotation from the integration library is how you distinguish between the two. Here is a concrete example from the tutorial’s contact detail view:

@Controller
@RequestMapping("/contacts")
public class ContactController {
    private final ContactRepository contactRepository;
    // Full page load — browser navigation or direct URL access
    @GetMapping("/{id}")
    public String viewContact(@PathVariable Long id, Model model) {
        model.addAttribute("contact", contactRepository.findById(id).orElseThrow());
        model.addAttribute("contacts", contactRepository.findAll());
        model.addAttribute("contactCount", contactRepository.count());
        model.addAttribute("contactForm", new ContactForm());
        return "contacts/list";  // Returns the full page with layout
    }
    // Fragment request — triggered by HTMX
    @HxRequest
    @GetMapping("/{id}")
    public String viewContactFragment(@PathVariable Long id, Model model) {
        model.addAttribute("contact", contactRepository.findById(id).orElseThrow());
        return "contacts/list :: contactDetail";  // Returns only the fragment
    }
}

The :: contactDetail syntax is a Thymeleaf fragment selector. It tells Thymeleaf to render only the element marked with th:fragment="contactDetail" from the contacts/list template, rather than the entire page. This is the foundation of every HTMX interaction: the server renders a piece of HTML, and HTMX swaps it into the right place in the DOM.

A note on hx-boost: If you use hx-boost="true" on the body (covered later in this article), be careful with @HxRequest on your main list endpoints. Boosted navigation sends the HX-Request header, which means @HxRequest handlers fire even for what looks like a normal page navigation. This is fine for targeted interactions like viewing a contact detail, but for your main page endpoint you generally want a single handler that returns the full page — letting hx-boost handle the body swap automatically.

Your Thymeleaf template defines fragments inline. The layout uses Thymeleaf’s native fragment system with th:replace for shared elements:

<!-- contacts/list.html -->
<html xmlns:th="http://www.thymeleaf.org"
      xmlns:hx="http://www.w3.org/1999/xhtml">
<head th:replace="~{layout/main :: head('Contacts')}"></head>
<body hx-boost="true" hx-ext="response-targets">
    <nav th:replace="~{layout/main :: nav}"></nav>
    <main>
        <table id="contact-table">
          <thead>
            <tr><th>Name</th><th>Email</th><th>Actions</th></tr>
          </thead>
          <tbody th:fragment="contactTable" id="contact-table-body">
            <tr th:each="contact : ${contacts}">
              <td th:text="${contact.name}"></td>
              <td th:text="${contact.email}"></td>
              <td>
                <button hx:delete="@{/contacts/{id}(id=${contact.id})}"
                        hx-target="closest tr"
                        hx-swap="outerHTML swap:500ms"
                        hx-confirm="Delete this contact?">
                  Delete
                </button>
              </td>
            </tr>
          </tbody>
        </table>
    </main>
    <div th:replace="~{layout/main :: scripts}"></div>
</body>
</html>

When HTMX fires a DELETE request for a specific contact, the server processes the deletion. The hx-target="closest tr" tells HTMX to replace the nearest table row, and hx-swap="outerHTML swap:500ms" adds a 500ms CSS transition for the removal. Since the server returns an empty primary response (only an OOB counter update — more on that next), the row is effectively removed from the DOM. This single pattern — controller returns HTML, HTMX swaps it in — covers the majority of interactions you will build.

 


Out-of-Band Swaps: The Killer Feature Nobody Explains

Most HTMX tutorials stop at single-target swaps. In real applications, a single user action frequently needs to update multiple, non-adjacent regions of the page. Deleting a contact should update the table and the “Total Contacts: 42” counter in the header. Adding an item to a cart should update the cart table and the cart badge in the navigation bar.

This is where Out-of-Band (OOB) swaps become essential. An OOB swap lets the server include additional HTML fragments in the response, each tagged with hx-swap-oob="true" and an id that matches an element on the page. HTMX will swap each one into its corresponding location automatically.

Spring Boot supports this natively through FragmentsRendering. Here is how the tutorial’s delete handler works — the delete button has hx-target="closest tr" and hx-swap="outerHTML", so the server only needs to return the OOB counter update:

@HxRequest
@DeleteMapping("/{id}")
public View deleteContact(@PathVariable Long id, Model model) {
    contactRepository.deleteById(id);
    model.addAttribute("contactCount", contactRepository.count());
    // Return only the OOB count badge.
    // The delete button's hx-swap="outerHTML" on "closest tr" replaces
    // the row with the (empty) primary content, removing it from the table.
    // The count badge has hx-swap-oob="true" in its fragment template,
    // so HTMX finds #contact-count on the page and updates it.
    // One request → row removed + count updated.
    return FragmentsRendering
            .fragment("contacts/list :: contactCount")
            .build();
}

The template needs two things: the inline element that displays on the page, and a separate fragment definition with the OOB attribute for use in HTMX responses:

<!-- Inline badge — rendered on full-page loads (no OOB attribute) -->
<span id="contact-count"
      class="badge"
      th:text="'Total: ' + ${contactCount}">
</span>
<!-- OOB fragment definition — hidden from full-page rendering via th:remove="all",
     but still available when called as a fragment by FragmentsRendering -->
<div th:remove="all">
    <span th:fragment="contactCount"
          id="contact-count"
          hx-swap-oob="true"
          class="badge"
          th:text="'Total: ' + ${contactCount}">
    </span>
</div>

Why two versions? This is a subtle but important detail. If you put hx-swap-oob="true" directly on the inline element, it works fine for HTMX fragment responses. But if you also use hx-boost="true" on the body (which we do), boosted navigation returns the full page as an HTMX response. During a boosted swap, HTMX extracts all OOB elements from the response body before performing the main body swap — and then the main swap replaces the body content without those extracted elements. The badge disappears. Separating the inline element from the OOB fragment definition, using th:remove="all" to hide the fragment from full-page rendering, avoids this entirely.

When HTMX receives the response, it scans for elements with hx-swap-oob="true". Each OOB element is matched by its id to an element already on the page and swapped in place. The result is that a single HTTP request updates two completely separate parts of the page, with no client-side JavaScript and no additional AJAX calls.

This pattern is critical for building real applications. Without it, you either resort to full-page reloads (defeating the purpose of HTMX) or start writing JavaScript to coordinate multiple requests (defeating the purpose of simplicity).

Contact table after deleting Eve Davis showing four remaining contacts and Total 4 badge updated simultaneously via HTMX Out-of-Band swap in a single request

Avoiding HTML Parser Corruption in Multi-Fragment Responses

There is one important constraint when using FragmentsRendering with multiple fragments: avoid mixing <tbody> fragments with non-table fragments in the same response. When HTMX receives a multi-fragment response, it parses the fragments by setting innerHTML on a temporary <div>. The browser’s HTML parser considers <tbody> invalid inside a <div>, and its error-recovery behaviour can corrupt subsequent elements in the response — causing <form> elements to become self-closing, losing their children.

The solution is to use HX-Trigger headers to trigger separate re-fetches for table data, rather than including <tbody> fragments alongside other elements. We will see this pattern in the error handling section next.


HTMX and Spring Security: Solving the CSRF Token Problem

If you add Spring Security to an HTMX project and start making POST, PUT, or DELETE requests, every single one will fail with a 403 Forbidden response. This is because Spring Security enables CSRF protection by default, and HTMX does not automatically include CSRF tokens in its requests.

This is the number one friction point developers hit when integrating HTMX with Spring Boot, and the solutions floating around online range from “just disable CSRF” (terrible advice for session-based apps) to jQuery-era meta tag approaches that miss the elegant solution the htmx-spring-boot-thymeleaf library already provides.

The Thymeleaf Dialect Handles It Automatically

When you use the hx: namespace from the Thymeleaf dialect (e.g., hx:post instead of hx-post), the library automatically injects the CSRF token into the request headers via an hx-headers attribute. This works for hx:post, hx:put, hx:patch, and hx:delete — even on elements that are not inside a <form> tag.

<!-- Using hx: namespace — CSRF token is injected automatically -->
<button hx:delete="@{/contacts/{id}(id=${contact.id})}"
        hx-target="closest tr"
        hx-swap="outerHTML">
  Delete
</button>

If you prefer to use the plain hx- attributes (without the Thymeleaf dialect namespace), you need to handle CSRF yourself. The cleanest approach is to add a global event listener in your layout template:

<meta name="_csrf" th:content="${_csrf.token}"/>
<meta name="_csrf_header" th:content="${_csrf.headerName}"/>
<script>
  document.body.addEventListener('htmx:configRequest', function(event) {
    let token = document.querySelector('meta[name="_csrf"]').content;
    let header = document.querySelector('meta[name="_csrf_header"]').content;
    event.detail.headers[header] = token;
  });
</script>

This attaches to every HTMX request and injects the token as an HTTP header. It is a one-time setup in your layout file and works globally across all pages.

Handling Authentication Failures Gracefully

The second security pitfall is what happens when a user’s session expires mid-interaction. Without special handling, Spring Security redirects to the login page — and HTMX dutifully swaps the entire login page HTML into whatever small hx-target the user clicked. The result is a login form crammed into a table cell or a modal body.

The htmx-spring-boot library provides HxRefreshHeaderAuthenticationEntryPoint to solve this. Instead of a redirect, it sends an HX-Refresh: true header, which tells HTMX to perform a full-page browser reload. The user then sees the login page as intended.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    var entryPoint = new HxRefreshHeaderAuthenticationEntryPoint();
    var requestMatcher = new RequestHeaderRequestMatcher("HX-Request");
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/", "/css/**", "/js/**").permitAll()
            .anyRequest().authenticated()
        )
        .formLogin(form -> form.loginPage("/login").permitAll())
        .exceptionHandling(ex -> ex
            .defaultAuthenticationEntryPointFor(entryPoint, requestMatcher)
        );
    return http.build();
}

Spring Security login page with username and password fields that users see after HTMX triggers a full page refresh on session expiry via HX-Refresh header

The RequestHeaderRequestMatcher("HX-Request") ensures this entry point only applies to HTMX requests. Regular browser navigation still gets the standard redirect behavior.


Error Handling in HTMX: Server Fragments and the Response Targets Extension

In a traditional SPA, the frontend catches HTTP error responses and displays error messages. With HTMX, you need the server to return error-state HTML, or you need to hook into HTMX’s event system on the client side. In practice, you will use both.

Server-Side: Return Error Fragments

For validation errors and expected failures, the cleanest approach is to have the controller return an HTML fragment that contains the error state. The form targets itself (hx-target="#contact-form-section"), so validation errors re-render the form with inline messages while the rest of the page stays untouched:

@HxRequest
@PostMapping
public Object createContact(@Valid ContactForm contactForm,
                            BindingResult bindingResult,
                            Model model,
                            HttpServletResponse response) {
    if (bindingResult.hasErrors()) {
        // Re-render the form with validation errors shown inline.
        model.addAttribute("contactForm", contactForm);
        return "contacts/list :: contactForm";
    }
    // Save the new contact
    contactRepository.save(contactForm.toEntity());
    // Reset the form and update the count badge via OOB swap.
    model.addAttribute("contactForm", new ContactForm());
    model.addAttribute("contactCount", contactRepository.count());
    // HX-Trigger tells the contact table to re-fetch its content.
    response.setHeader("HX-Trigger", "contactsUpdated");
    return FragmentsRendering
            .fragment("contacts/list :: contactForm")
            .fragment("contacts/list :: contactCount")
            .build();
}

The HX-Trigger header is worth explaining. On success, the controller needs to update three things: the form (reset it), the count badge, and the contact table. The form and count badge are returned directly in the response via FragmentsRendering. But including a <tbody> fragment alongside the form would cause HTML parser corruption (see the OOB section above). Instead, the controller sends HX-Trigger: contactsUpdated, and the table body listens for this event:

<tbody th:fragment="contactTable" id="contact-table-body"
       hx-get="/contacts/search?q="
       hx-trigger="contactsUpdated from:body"
       hx-swap="outerHTML">

When the contactsUpdated event fires, the <tbody> re-fetches its content from the search endpoint. One response triggers three updates: form reset, count badge (OOB), and table refresh (HX-Trigger). No <tbody> in the form response, no parser corruption.

This is one of the places where server-side rendering genuinely shines. The error handling is the same code path you would use for a full-page form submission. There is no API contract to maintain, no error-code-to-message mapping on the client, and no risk of the frontend and backend falling out of sync on validation rules.

HTMX form submission showing inline validation errors with red highlighted Name is required and Email is required messages rendered by Thymeleaf

Client-Side: The Response Targets Extension

For unexpected errors (500s, timeouts, network failures), HTMX provides the response-targets extension. This lets you define alternative swap targets for different HTTP status codes:

<body hx-ext="response-targets">
  <div id="error-banner" class="hidden"></div>
  <form hx:post="@{/contacts}"
        hx-target="#contact-form-section"
        hx-swap="outerHTML"
        hx-target-500="#error-banner"
        hx-target-4xx="#error-banner">
    <!-- form fields -->
  </form>
</body>

Include the extension script alongside HTMX:

<script src="https://unpkg.com/htmx-ext-response-targets@2.0.2/response-targets.js"></script>

On the server side, your @ControllerAdvice can return appropriate error fragments:

@ControllerAdvice
public class HtmxErrorHandler {
    @ExceptionHandler(Exception.class)
    @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
    public String handleError(Exception ex, Model model) {
        model.addAttribute("errorMessage", "Something went wrong. Please try again.");
        return "fragments/error-banner";
    }
}

This pattern gives you graceful degradation: validation errors re-render the form with inline messages, while server errors display a banner — all without writing client-side error handling logic.


The htmx.process() Gotcha: Dynamically Injected Content

This is a problem that burns developers who combine HTMX with any client-side DOM manipulation, whether through Alpine.js, a modal library, or even a simple JavaScript innerHTML assignment. It is poorly documented in JVM contexts and is the source of many confused Stack Overflow questions.

The problem: When HTMX initialises on page load, it scans the DOM for hx-* attributes and registers event listeners on those elements. If you later inject HTML into the page using something other than HTMX itself (e.g., Alpine.js’s x-if directive, a JavaScript-powered modal, or a web component), the hx-* attributes on those injected elements will not work. HTMX has never seen them.

The solution: Call htmx.process(element) on the newly injected DOM node. This tells HTMX to scan the element and its children for hx-* attributes and initialise them.

// After injecting content via Alpine.js, a modal library, or vanilla JS:
let newContent = document.getElementById('dynamically-added-section');
htmx.process(newContent);

If you are using Alpine.js alongside HTMX (a popular combination for client-side state), the pattern looks like this:

<div x-data="{ showForm: false }">
  <button @click="showForm = true">Add Contact</button>
  <template x-if="showForm">
    <div x-init="htmx.process($el)">
      <form hx:post="@{/contacts}"
            hx-target="#contact-table tbody"
            hx-swap="beforeend">
        <!-- form fields -->
      </form>
    </div>
  </template>
</div>

JavaScript-injected dynamic content section with a Reload All Contacts HTMX-powered button that requires htmx.process to initialise hx attributes on DOM elements not inserted by HTMX

The x-init="htmx.process($el)" directive runs when Alpine.js inserts the template into the DOM, ensuring HTMX picks up the hx-post attribute on the form. Without this, the form submission would fall through to a standard browser POST — a full-page navigation that bypasses your HTMX fragment rendering entirely.

If you are injecting content exclusively via HTMX itself (e.g., through hx-get loading a fragment), you do not need htmx.process(). HTMX automatically processes content that it inserts. This gotcha only applies when other code manipulates the DOM.


Production Patterns: Loading Indicators, Search Debouncing, and hx-boost

Loading Indicators

HTMX provides built-in support for loading indicators via the hx-indicator attribute and the htmx-request CSS class. When a request is in flight, HTMX adds the htmx-request class to the indicator element:

<button hx:get="@{/reports/generate}"
        hx-target="#report-container"
        hx-indicator="#report-spinner">
  Generate Report
</button>
<span id="report-spinner" class="htmx-indicator">
  Loading...
</span>
.htmx-indicator {
  display: none;
}
.htmx-request .htmx-indicator,
.htmx-request.htmx-indicator {
  display: inline;
}

For a global loading bar (similar to NProgress or the GitHub loading bar), you can listen to the htmx:beforeRequest and htmx:afterRequest events:

document.body.addEventListener('htmx:beforeRequest', function() {
  document.getElementById('global-progress').classList.add('active');
});
document.body.addEventListener('htmx:afterRequest', function() {
  document.getElementById('global-progress').classList.remove('active');
});

Search Debouncing

Live search is one of HTMX’s strongest use cases. The hx-trigger attribute supports modifiers that eliminate the need for debounce libraries:

<input type="search"
       name="q"
       hx:get="@{/contacts/search}"
       hx-trigger="keyup changed delay:300ms"
       hx-target="#contact-table-body"
       hx-swap="outerHTML"
       hx-indicator="#search-spinner"
       placeholder="Search contacts..." />

The delay:300ms modifier tells HTMX to wait 300 milliseconds after the last keyup event before firing the request. The changed modifier ensures HTMX only fires if the value has actually changed (preventing duplicate requests when the user presses arrow keys or Shift). This two-modifier combination provides proper debouncing with zero JavaScript.

Live search filtering contacts table by typing alice with HTMX debounced keyup trigger showing single matching result

On the server side, the controller is straightforward:

@HxRequest
@GetMapping("/search")
public String searchContacts(@RequestParam(defaultValue = "") String q, Model model) {
    if (q.isBlank()) {
        model.addAttribute("contacts", contactRepository.findAll());
    } else {
        model.addAttribute("contacts", contactRepository.searchByName(q));
    }
    return "contacts/list :: contactTable";
}

Progressive Enhancement with hx-boost

Adding hx-boost="true" to a parent element (typically the <body> tag) tells HTMX to intercept all standard anchor clicks and form submissions within that element and convert them into AJAX requests. The response replaces the <body> content, giving you SPA-like navigation without changing a single link or form in your templates:

<body hx-boost="true">
  <!-- All links and forms within this body are now AJAX-powered -->
  <nav>
    <a href="/contacts">Contacts</a>
    <a href="/reports">Reports</a>
  </nav>
  <main id="content">
    <!-- Page content swapped here -->
  </main>
</body>

The beauty of hx-boost is that it is purely progressive enhancement. If JavaScript is disabled, every link and form still works as a standard server request. You can adopt HTMX incrementally in an existing Spring Boot application by adding this single attribute, and every page transition immediately becomes faster because only the body content is replaced — not the entire page with its CSS, JavaScript, and layout.

Your controllers do not need to change at all for hx-boost. Thymeleaf layouts work as expected because HTMX strips the outer HTML and swaps only the body content.

History and the Back Button

HTMX manages browser history through the hx-push-url attribute. When set to true, HTMX pushes the request URL onto the browser’s history stack after a successful swap:

<a hx:get="@{/contacts/{id}(id=${contact.id})}"
   hx-target="#contact-detail"
   hx-swap="innerHTML"
   hx-push-url="true"
   th:text="${contact.name}">
</a>

Contact detail panel for Alice Johnson displayed below the contacts table after clicking her name using HTMX fragment swap with hx-push-url

Note the explicit hx-swap="innerHTML" on the link. This is necessary because the link sits inside a <tbody> that has hx-swap="outerHTML" for its own re-fetch trigger. HTMX inherits hx-swap from parent elements, so without the explicit override, the contact detail container would be replaced entirely (outerHTML) instead of having its content updated (innerHTML).

When the user clicks the back button, HTMX restores the previous page state from its internal cache. If the cache has expired, it re-fetches the previous URL.

For hx-boost, history support is enabled by default — you do not need to add hx-push-url to boosted links.


HTMX vs React Architecture: A Side-by-Side Comparison

For developers coming from the SPA + REST API world, here is how the architecture shifts:

ConcernSPA + REST APIHTMX + Spring Boot
RenderingClient-side (React/Angular)Server-side (Thymeleaf)
Data formatJSONHTML fragments
State managementRedux / Zustand / ContextServer is the source of truth
ValidationDuplicated (client + server)Server only
Build toolingWebpack/Vite + npmGradle/Maven only
Deployment2 artifacts (frontend + backend)1 JAR
SEORequires SSR/SSG workaroundsNative (HTML from the server)
Bundle size120 KB+ (React + ReactDOM)14 KB (HTMX)
InteractivityFull SPA capabilityServer-driven with selective JS

The tradeoff is explicit: you are giving up the rich client-side interactivity of a dedicated frontend framework in exchange for architectural simplicity, faster time-to-production, and a single codebase. For most business applications, this is a trade worth making.


When NOT to Use HTMX

Being honest about limitations builds more trust than pretending they do not exist. HTMX is not the right tool when:

Your application requires offline functionality. HTMX is fundamentally server-dependent. Every interaction requires a round trip. If your users need to work without a network connection, you need a client-side application with a synchronization layer.

You are building a real-time collaborative editor. Google Docs-style concurrent editing requires sophisticated conflict resolution and operational transforms that live on the client. HTMX’s request-response model does not map to this problem.

Your UI involves complex, interdependent client-side state. Think a multi-step wizard where every field in step 3 depends on choices made in steps 1 and 2, with instant client-side previews. While you can make this work with HTMX (each interaction triggers a server round trip), the latency may be unacceptable, and a client-side state machine would be more appropriate.

Your team already has deep React/Angular expertise and an established workflow. Switching to HTMX for the sake of it, when your team is productive with an existing stack, is not a good engineering decision. HTMX is most valuable for teams starting new projects, teams with stronger backend than frontend skills, or teams maintaining applications where the SPA complexity is outweighing its benefits.

For everything else — and “everything else” is a surprisingly large category — HTMX with Spring Boot is a production-ready architecture that will save you hundreds of hours over the lifetime of the project.


Getting Started: Clone the Working Example

If you are evaluating HTMX for an existing Spring Boot application, the lowest-risk approach is to pick a single page with a simple interaction (a search form, a delete button, a filter dropdown) and convert it. Add the HTMX script tag, add hx-boost="true" to the body, and create a fragment endpoint for that one interaction. You will have a working proof of concept in less than an hour.

A complete working example — a Contact Manager demonstrating every pattern in this article — is available on GitHub: tucanoo/spring-boot-htmx-tutorial. Clone it, run ./gradlew bootRun, and explore the heavily commented code alongside this guide.

The key libraries and resources to bookmark:

  • htmx-spring-boot by Wim Deblauwe — the integration library used throughout this guide. Provides @HxRequest, the Thymeleaf dialect, and Spring Security helpers.
  • Spring Framework HTML Fragments documentation — covers the FragmentsRendering API for Out-of-Band swaps.
  • HTMX documentation at htmx.org — the reference for all hx-* attributes and extensions.

The modular monolith is not a step backwards. It is a recognition that most applications do not need the complexity that the industry has been defaulting to. HTMX with Spring Boot is one of the most practical expressions of that recognition: a boring stack that ships fast, scales well, and lets you focus on solving your users’ problems instead of fighting your tools.


At Tucanoo, we specialise in Spring Boot and Grails application development, modernisation, and architecture consulting. If your team is evaluating the modern monolith approach or needs help migrating from a complex SPA architecture, get in touch.

Next in this series: HTMX with Grails 6 and GSP — bringing the same patterns to the Grails ecosystem, including GORM integration and the grails-htmx plugin.

David Brown founded Tucanoo Solutions in 2011 and has written software in a professional capacity for over 30 years, back to the Java/J2EE days of Sun Microsystems. He builds cloud and web applications for clients in legal tech, government and beyond, including several years consulting to the United Nations on customs and transport systems. He writes these tutorials himself, with the full code on GitHub.

Leave a Reply

Your email address will not be published. Required fields are marked *