Core Java

HTMX in a Java World: Building Hypermedia APIswith Spring Boot and Thymeleaf

Tired of wiring up React for a search box? HTMX gives your Thymeleaf templates superpowers — live search, inline edits, out-of-band DOM updates — with zero JavaScript of your own and a controller you already know how to write.

Why HTMX Matters for Java Developers

Here is a situation most backend Java developers know well. A product owner asks for a simple search-as-you-type feature. You know the backend logic takes about twenty minutes to write. But by the time you have scaffolded a React component, set up Axios, wired up state management, dealt with CORS, and configured the build pipeline, half a day has gone. The feature itself was never the hard part.

HTMX is a direct answer to that problem. It is a small JavaScript library — around 14 KB unminified — that lets HTML elements make HTTP requests and swap parts of the page with the response. There is no virtual DOM, no component lifecycle, and no client-side state management. The server stays in charge. The browser just does what the attributes say.

For Java developers already working with Spring Boot and Thymeleaf, the fit is almost unnervingly natural. Your controller already returns HTML. Thymeleaf already knows how to render fragments of a page. HTMX simply gives you a way to ask for those fragments on demand, without a full page reload. As one IntelliJ IDEA blog post on the topic summarizes it: “you can layer it in incrementally — some pages may utilize HTMX heavily, while others do not mention it.”

Frontend Stack Complexity vs. Interactivity Needed

Rough positioning of common approaches — heavier stacks pay an upfront cost independent of feature complexity

Furthermore, HTMX aligns with the Hypermedia as the Engine of Application State (HATEOAS) constraint that Roy Fielding described in his original REST dissertation — the idea that the server’s HTML response itself drives what the client can do next, rather than the client building its own understanding of state. That philosophical alignment with REST’s original intent is something the HTMX community talks about a great deal, and it resonates strongly in a Spring context.

How HTMX Works: The Six Attributes You Actually Need

Before writing any Spring code, it is worth grounding yourself in the core HTMX mechanics. There are dozens of hx-* attributes, but realistically six of them cover ninety percent of everything you will build.

AttributeWhat It DoesExample
hx-getIssues a GET to the given URL on the trigger eventhx-get="/products/search"
hx-postIssues a POST — form values and named inputs are includedhx-post="/products/add"
hx-targetCSS selector for the element to update with the responsehx-target="#results"
hx-swapControls how the response replaces the target (innerHTMLouterHTMLbeforeend, etc.)hx-swap="outerHTML"
hx-triggerThe event that fires the request. Supports modifiers like delaychangedoncehx-trigger="keyup changed delay:400ms"
hx-boostUpgrades normal anchor links and forms to AJAX — progressive enhancement in one attribute<body hx-boost="true">

The mental model is straightforward. An element with hx-get or hx-post describes the request. hx-target describes where the response goes. hx-swap describes how it gets inserted. hx-trigger describes what causes it all to start. That is essentially the entire programming model for most HTMX applications.

“HTMX works by converting back and forth between JSON and HTML. Think of it as a kind of declarative Ajax.”— InfoWorld, HTMX for Java with Spring Boot and Thymeleaf

Project Setup: Spring Boot + Thymeleaf + HTMX

Getting the stack running is genuinely quick. Start at start.spring.io and add Spring Web and Thymeleaf as dependencies. That is all Spring needs. HTMX itself is just a script tag — no build step, no npm install.

 pom.xml — minimal dependencies

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-thymeleaf</artifactId>
    </dependency>
    <!-- Optional but recommended: htmx-spring-boot helper library -->
    <dependency>
        <groupId>io.github.wimdeblauwe</groupId>
        <artifactId>htmx-spring-boot-thymeleaf</artifactId>
        <version>4.0.1</version>
    </dependency>
</dependencies>

Your base layout template then includes HTMX via a CDN script tag. You can also use WebJars to manage it through Maven if you prefer not to depend on an external CDN in production.

  resources/templates/layout.html — base layout

<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title th:text="${pageTitle}">App</title>
    <!-- HTMX: no npm, no build step -->
    <script src="/https://unpkg.com/htmx.org@2.0.3"
            integrity="sha384-..."
            crossorigin="anonymous"></script>
</head>
<body hx-boost="true">
    <!-- hx-boost="true" upgrades all links/forms to AJAX for free -->
    <div th:replace="~{:: content}"></div>
</body>
</html>

To avoid the CDN dependency, add org.webjars:htmx.org:2.0.3 to your pom.xml and reference it as /webjars/htmx.org/dist/htmx.min.js. Spring Boot’s WebMvcAutoConfiguration automatically maps the /webjars/** path, so no extra configuration is needed.

Thymeleaf Fragments: The Engine Room

Thymeleaf’s fragment system is what makes the Spring + HTMX combination work so well. A fragment is simply a named section of a template — a div, a tbody, a form — that can be returned on its own without the surrounding page structure. When HTMX fires a request and your controller returns a fragment, only that piece of HTML lands in the browser. The rest of the page is untouched.

The key pattern is to define the fragment inside a full template so the page works as a normal server-rendered page on first load, and then have a second controller mapping — detected by the presence of the HX-Request header — that returns only the fragment for subsequent HTMX calls. This ensures the page is fully functional without JavaScript, which is both good practice and important for accessibility.

Thymeleaf’s :: syntax selects a named fragment from a template. The return value "products :: list" tells Spring to render the products.html template but return only the element marked with th:fragment="list". This means your fragment definition lives in exactly one place — no duplication, no separate partial files.

Feature: Live Search With Debounce

Let’s build the first real feature: a live search box that filters a product table as the user types, calling the server with each keystroke (debounced to avoid flooding it). This is probably the most commonly requested HTMX pattern, and in a Spring Boot context it requires surprisingly little code on either side.

The Thymeleaf Template

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<body>

<!-- Search input: fires on keyup, waits 400ms, only if value changed -->
<input type="search"
       name="query"
       placeholder="Search products..."
       hx-get="/products/search"
       hx-trigger="keyup changed delay:400ms, search"
       hx-target="#product-list"
       hx-swap="innerHTML" />

<!-- This tbody gets replaced by HTMX on each search -->
<table>
    <thead>
        <tr><th>Name</th><th>Category</th><th>Price</th></tr>
    </thead>
    <tbody id="product-list" th:fragment="list">
        <tr th:each="product : ${products}">
            <td th:text="${product.name}"></td>
            <td th:text="${product.category}"></td>
            <td th:text="${'$' + product.price}"></td>
        </tr>
        <tr th:if="${#lists.isEmpty(products)}">
            <td colspan="3">No products found.</td>
        </tr>
    </tbody>
</table>

</body>
</html>

The Spring Controller

  ProductController.java

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

@Controller
public class ProductController {

    private final ProductRepository productRepository;

    public ProductController(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    // Full page load — returns the complete template
    @GetMapping("/products")
    public String productsPage(Model model) {
        model.addAttribute("products", productRepository.findAll());
        return "products";
    }

    // HTMX partial request — returns only the table body fragment
    @GetMapping("/products/search")
    public String searchProducts(@RequestParam(defaultValue = "") String query, Model model) {
        model.addAttribute("products", productRepository.searchByName(query));
        return "products :: list";   // Thymeleaf :: fragment selector
    }
}

Feature: Inline Edit and Delete

The next step up in interactivity is inline editing — clicking a row’s edit button and having the row transform into an input form in place, without navigation, modals, or page reloads. This pattern shows where the Spring + HTMX combination really shines, because the server stays completely in control of what HTML state a row is in.

  Fragment: display row vs. edit row (same template)

<!-- READ mode: shown by default -->
<tr th:fragment="product-row(product)" th:id="'product-' + ${product.id}">
    <td th:text="${product.name}"></td>
    <td th:text="${product.category}"></td>
    <td>
        <!-- Swap THIS entire row with the edit form -->
        <button hx-get="/products/edit-form/${product.id}"
                hx-target="#product-${product.id}"
                hx-swap="outerHTML">Edit</button>
        <button hx-delete="/products/${product.id}"
                hx-target="#product-${product.id}"
                hx-swap="outerHTML"
                hx-confirm="Delete this product?">Delete</button>
    </td>
</tr>

<!-- EDIT mode: returned by the server when Edit is clicked -->
<tr th:fragment="product-edit-row(product)" th:id="'product-' + ${product.id}">
    <form th:action="@{/products/{id}(id=${product.id})}" method="post">
        <td><input type="text" name="name" th:value="${product.name}" /></td>
        <td><input type="text" name="category" th:value="${product.category}" /></td>
        <td>
            <button type="submit"
                    hx-post="/products/${product.id}"
                    hx-target="#product-${product.id}"
                    hx-swap="outerHTML">Save</button>
            <button hx-get="/products/row/${product.id}"
                    hx-target="#product-${product.id}"
                    hx-swap="outerHTML">Cancel</button>
        </td>
    </form>
</tr>

 Controller endpoints for inline edit

// Returns the edit form row
@GetMapping("/products/edit-form/{id}")
public String getEditForm(@PathVariable Long id, Model model) {
    model.addAttribute("product", productRepository.findById(id).orElseThrow());
    return "products :: product-edit-row(product=${product})";
}

// Handles save — returns the updated read-mode row
@PostMapping("/products/{id}")
public String updateProduct(@PathVariable Long id,
                            @RequestParam String name,
                            @RequestParam String category,
                            Model model) {
    Product saved = productRepository.save(new Product(id, name, category));
    model.addAttribute("product", saved);
    return "products :: product-row(product=${product})";
}

// Returns the plain read row (used by Cancel)
@GetMapping("/products/row/{id}")
public String getProductRow(@PathVariable Long id, Model model) {
    model.addAttribute("product", productRepository.findById(id).orElseThrow());
    return "products :: product-row(product=${product})";
}

// Handles delete — returns empty string to remove the row
@DeleteMapping("/products/{id}")
@ResponseBody
public String deleteProduct(@PathVariable Long id) {
    productRepository.deleteById(id);
    return "";   // Empty response: hx-swap="outerHTML" removes the row entirely
}

Notice how the controller never returns JSON. Every response is a Thymeleaf fragment. The server owns the state of each row — whether it is in read mode or edit mode — and the client simply swaps what it receives. That is the hypermedia approach in practice, and it is remarkably clean to reason about.

Out-of-Band Swaps: Updating Multiple Page Regions

One limitation of the basic HTMX model is that a request can only target a single element. But real applications often need to update several unrelated parts of the page from a single action — for example, adding an item to a cart should update both the item list and the cart count badge in the header. This is where Out-of-Band (OOB) swaps come in.

The hx-swap-oob attribute allows you to specify that some content in a response should be swapped into the DOM somewhere other than the target, that is “Out of Band”. Essentially, a single HTTP response can contain multiple HTML pieces. The primary piece goes into hx-target as usual, and any additional pieces marked with hx-swap-oob are matched by their id and swapped into the corresponding element wherever it lives on the page.

  Controller returning multiple fragments via FragmentsRendering

import org.springframework.web.servlet.view.FragmentsRendering;
import org.springframework.web.servlet.View;

// Spring's FragmentsRendering API lets you return multiple template fragments
// from one controller method — the cleanest way to handle OOB swaps in Spring MVC
@PostMapping("/cart/add/{productId}")
public View addToCart(@PathVariable Long productId, Model model) {
    cartService.add(productId);

    model.addAttribute("cartItems", cartService.getItems());
    model.addAttribute("cartCount", cartService.getCount());

    return FragmentsRendering
            .with("cart :: item-list")       // primary target content
            .fragment("layout :: cart-badge") // OOB: updates cart count in navbar
            .build();
}

  Template fragments — layout.html (navbar badge) + cart.html

<!-- In layout.html: the cart badge lives in the navbar -->
<span id="cart-badge" th:fragment="cart-badge"
      th:text="${cartCount} + ' items'">0 items</span>

<!-- In cart.html: the item list is the primary swap target -->
<ul id="cart-item-list" th:fragment="item-list">
    <li th:each="item : ${cartItems}" th:text="${item.name}"></li>
</ul>

<!-- The Add to Cart button -->
<button hx-post="/cart/add/42"
        hx-target="#cart-item-list"
        hx-swap="innerHTML">Add to Cart</button>

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. Separating the inline element from the OOB fragment definition, using th:remove="all" to hide the fragment from full-page rendering, avoids this entirely. Keep your OOB fragments in templates that are only rendered as fragments, not as part of a full-page response.

The htmx-spring-boot Library

For anything beyond basic patterns, the htmx-spring-boot library by Wim Deblauwe is the most important tool in your kit. The project simplifies the integration of htmx with Spring Boot/Spring Web MVC applications. It provides a set of views, annotations, and argument resolvers for controllers to easily handle htmx-related request and response headers.

The two most useful things it gives you are the @HxRequest annotation and the Thymeleaf dialect with hx: prefixed attributes. Together, they make controller code more expressive and templates more dynamic.

  Using @HxRequest to separate HTMX from regular requests

import io.github.wimdeblauwe.htmx.spring.boot.mvc.HxRequest;

@Controller
public class UserController {

    // Regular request: return the full page
    @GetMapping("/users")
    public String usersPage(Model model) {
        model.addAttribute("users", userRepository.findAll());
        model.addAttribute("count", userRepository.count());
        return "users";
    }

    // HTMX request: return only the list fragment + OOB count update
    @HxRequest                  // only matches requests with HX-Request: true header
    @GetMapping("/users")
    public View usersHtmx(Model model) {
        model.addAttribute("users", userRepository.findAll());
        model.addAttribute("count", userRepository.count());
        return FragmentsRendering
                .with("users :: list")
                .fragment("users :: count")
                .build();
    }
}

The hx: Thymeleaf dialect (note the colon, not a hyphen) enables Thymeleaf expressions inside HTMX attributes. This is especially useful when building URLs dynamically — for instance, hx:get="@{/products/{id}(id=${product.id})}" uses Thymeleaf’s URL syntax inside an HTMX attribute, which the plain hx-get static attribute cannot do.

@HxTrigger for server-sent eventsThe library also provides @HxTrigger — an annotation that adds the HX-Trigger response header, which tells HTMX to fire a named JavaScript event on the client after a swap completes. This is the cleanest way to coordinate cascading updates across unrelated page sections without writing JavaScript.

Spring Security and CSRF With HTMX

Adding Spring Security to an HTMX application introduces two specific issues that are worth knowing about before you hit them in production rather than after.

The first is CSRF tokens. Spring Security requires a CSRF token on any state-changing request (POST, PUT, DELETE). Standard HTML forms include the token automatically via Thymeleaf’s th:action. HTMX’s hx-post, however, is not a form — it is an AJAX call, and it does not automatically include the token. The htmx-spring-boot library extends this support by automatically injecting the CSRF token into the request headers through the hx-headers attribute of elements using hx:posthx:puthx:patch, or hx:delete, even if the element is not part of a form. Using the hx: dialect (not hx-) is therefore the easiest way to stay CSRF-safe.

The second issue is session expiry. Without special handling, an expired Spring Security session causes a redirect to the login page. HTMX then dutifully swaps the entire login page HTML into whatever small hx-target you have — typically a table row or a search result container. 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 sees the login page as expected.

  SecurityConfig.java — handle HTMX session expiry correctly

import io.github.wimdeblauwe.htmx.spring.boot.security.HxRefreshHeaderAuthenticationEntryPoint;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(login -> login
                .loginPage("/login").permitAll()
            )
            // When session expires during an HTMX request,
            // send HX-Refresh: true instead of redirecting to login
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(new HxRefreshHeaderAuthenticationEntryPoint())
            );
        return http.build();
    }
}

When to Use HTMX — and When Not To

HTMX is not a universal answer. Part of using it well is knowing honestly where it fits and where it does not. The comparison below reflects the consensus that has emerged from the community after several years of real-world use.

HTMX is a great fit

  • Admin dashboards and internal tools
  • CRUD-heavy line-of-business applications
  • Content management systems
  • Search, filter, and paginate UIs
  • Inline edit patterns
  • Progressive enhancement of existing Thymeleaf pages
  • Teams that want to stay in the Java/backend toolchain
  • Applications where SEO and server-side rendering matter

Consider a JS framework instead

  • Rich interactive UIs with complex local state (canvas, drag-and-drop grids)
  • Real-time collaboration features
  • Complex client-side form wizards with multi-step validation
  • Offline-capable progressive web apps
  • Applications requiring heavy client-side computation
  • Teams already proficient in React/Vue with existing component libraries

HTMX vs. React/Vue — Where Each Approach Wins

Scored 1–10 across common evaluation criteria for typical business applications

The most honest summary of the tradeoff: HTMX wins on simplicity, server-side control, and backend developer productivity for the wide category of “interactive but not complex” UIs. React and Vue win on richness of client-side interaction, component ecosystem, and suitability for genuinely application-like experiences. Many production codebases use both — HTMX for the majority of pages, a React component for the one screen that genuinely needs it.

What We Have Learned

HTMX gives Java developers a credible, pragmatic alternative to heavy JavaScript frontends for the broad category of business-facing web applications. The Spring Boot and Thymeleaf combination was already the right environment for HTMX before any extra library — Thymeleaf fragments map directly onto HTMX’s partial-response model, and Spring MVC controllers return HTML naturally. Adding the htmx-spring-boot library by Wim Deblauwe then elevates the experience significantly: @HxRequest separates full-page and partial controllers cleanly, the hx: Thymeleaf dialect handles CSRF automatically, and HxRefreshHeaderAuthenticationEntryPoint prevents the session-expiry problem from silently breaking HTMX-driven pages.

The patterns we walked through — live search with debounceinline edit and delete, and out-of-band swaps via FragmentsRendering — cover the vast majority of real interactivity requirements for CRUD-heavy business applications. None of them required a single line of JavaScript. The six core hx-* attributes (hx-gethx-posthx-targethx-swaphx-triggerhx-boost) are genuinely all you need to get started. The best entry point for an existing project is a single page with one interaction — add the script tag, mark one fragment, write one controller endpoint, and validate the approach before committing further.

Eleftheria Drosopoulou

Eleftheria is an Experienced Business Analyst with a robust background in the computer software industry. Proficient in Computer Software Training, Digital Marketing, HTML Scripting, and Microsoft Office, they bring a wealth of technical skills to the table. Additionally, she has a love for writing articles on various tech subjects, showcasing a talent for translating complex concepts into accessible content.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Oldest
Newest Most Voted
Back to top button