Post

HTMX + Spring Boot — Modern Server-Rendered UIs Without JavaScript Frameworks

Build modern, interactive UIs with Spring Boot and HTMX — no React, no Angular, no build step. Covers hx-get, hx-post, hx-swap, fragment rendering with Thymeleaf, and why this approach is gaining traction for internal tools and CRUD apps.

HTMX + Spring Boot — Modern Server-Rendered UIs Without JavaScript Frameworks

The Problem — SPA Fatigue for CRUD Apps

Every internal tool starts the same way. Someone says “we need a dashboard” and suddenly you’re configuring Webpack, setting up a React project, managing state with Redux, and writing 2000 lines of JavaScript to render a table with pagination.

For most CRUD apps — admin panels, internal tools, dashboards — a full SPA is overkill. You don’t need client-side routing. You don’t need a virtual DOM. You don’t need a 200MB node_modules folder just to display a form.

What you actually need: the ability to update parts of a page without a full reload. That’s it.

Enter HTMX.


What is HTMX

HTMX is a 14KB JavaScript library that extends HTML with attributes for making HTTP requests and swapping content. Instead of building a JSON API and rendering on the client, you return HTML fragments from the server and let HTMX swap them into the page.

The philosophy: hypermedia as the engine of application state (HATEOAS) — the way the web was designed to work.

sequenceDiagram
    participant Browser
    participant HTMX
    participant Server

    Browser->>HTMX: User clicks button (hx-get="/products/list")
    HTMX->>Server: GET /products/list
    Server-->>HTMX: HTML fragment (<tbody>...</tbody>)
    HTMX->>Browser: Swap fragment into #product-table-body
    Note over Browser: No full page reload, no JSON parsing

diagram

Key insight: the server owns the rendering. No duplication of templates between frontend and backend.


Setup

We need three dependencies:

1
2
3
4
5
6
7
8
9
10
11
12
13
<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>
<dependency>
    <groupId>org.webjars.npm</groupId>
    <artifactId>htmx.org</artifactId>
    <version>2.0.0</version>
</dependency>

Include HTMX in your template:

1
<script src="/webjars/htmx.org/dist/htmx.min.js"></script>

No npm install. No build step. One script tag.


HTMX Attributes

HTMX works through HTML attributes. Here are the ones we’ll use:

AttributePurposeExample
hx-getMake a GET requesthx-get="/products/list"
hx-postMake a POST requesthx-post="/products"
hx-deleteMake a DELETE requesthx-delete="/products/1"
hx-swapHow to swap the responsehx-swap="innerHTML" / "outerHTML" / "beforeend"
hx-targetWhich element to updatehx-target="#product-table-body"
hx-triggerWhen to fire the requesthx-trigger="click" / "input changed delay:500ms"
hx-confirmShow confirmation before requesthx-confirm="Are you sure?"
hx-indicatorShow loading indicatorhx-indicator="#spinner"

The mental model: any element can make any HTTP request and put the response anywhere on the page.


Thymeleaf Fragments — Partial Page Updates

The power of HTMX with Spring Boot comes from returning fragments — partial HTML that gets swapped into the existing page.

A full page template (products.html) renders the initial load. Subsequent interactions return only the parts that changed.

Product row fragment (fragments/product-row.html):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<tr th:id="'product-' + ${product.id()}">
    <td th:text="${product.id()}"></td>
    <td th:text="${product.name()}"></td>
    <td th:text="'$' + ${product.price()}"></td>
    <td>
        <button class="delete-btn"
                th:attr="hx-delete='/products/' + ${product.id()}"
                hx-confirm="Are you sure?"
                th:attr="hx-target='#product-' + ${product.id()}"
                hx-swap="outerHTML">
            Delete
        </button>
    </td>
</tr>

Product list fragment (fragments/product-list.html):

1
2
3
<th:block th:each="product : ${products}">
    <!-- renders each product row -->
</th:block>

The controller returns fragment view names:

1
2
3
4
5
@GetMapping("/list")
public String productList(Model model) {
    model.addAttribute("products", productService.findAll());
    return "fragments/product-list";
}

Building a Product CRUD — No JavaScript Needed

Here’s the complete controller:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
@Controller
@RequestMapping("/products")
public class ProductController {

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

    @GetMapping
    public String productsPage(Model model) {
        model.addAttribute("products", productService.findAll());
        return "products";
    }

    @GetMapping("/list")
    public String productList(Model model) {
        model.addAttribute("products", productService.findAll());
        return "fragments/product-list";
    }

    @PostMapping
    public String createProduct(@RequestParam String name,
                                @RequestParam BigDecimal price,
                                Model model) {
        Product product = productService.create(name, price);
        model.addAttribute("product", product);
        return "fragments/product-row";
    }

    @DeleteMapping("/{id}")
    @ResponseBody
    public String deleteProduct(@PathVariable Long id) {
        productService.delete(id);
        return "";
    }
}

Create — hx-post="/products" sends form data, server returns a new <tr>, HTMX appends it to the table body (hx-swap="beforeend").

Delete — hx-delete="/products/1" fires the request, server returns empty string, HTMX removes the row (hx-swap="outerHTML" replaces the row with nothing).

List — hx-get="/products/list" fetches the full table body fragment and replaces the current content.

Zero JavaScript written. The browser handles everything.


One of HTMX’s killer features: real-time search with a single attribute.

1
2
3
4
5
6
7
<input type="search"
       name="query"
       placeholder="Search products..."
       hx-get="/products/search"
       hx-trigger="input changed delay:500ms, search"
       hx-target="#product-table-body"
       hx-swap="innerHTML"/>

The hx-trigger="input changed delay:500ms" means: fire the request 500ms after the user stops typing, but only if the value actually changed. Built-in debouncing with no JavaScript.

The controller:

1
2
3
4
5
@GetMapping("/search")
public String searchProducts(@RequestParam(defaultValue = "") String query, Model model) {
    model.addAttribute("products", productService.search(query));
    return "fragments/product-list";
}

Infinite Scroll

HTMX supports infinite scroll out of the box:

1
2
3
4
5
<tr hx-get="/products/page?page=2"
    hx-trigger="revealed"
    hx-swap="afterend">
    <td colspan="4">Loading more...</td>
</tr>

The hx-trigger="revealed" fires when the element scrolls into view. The response includes the next page of rows plus another trigger row for the following page. Pagination without a single line of JavaScript.


HTMX vs React/Angular

CriteriaHTMXReact/Angular
Bundle size14KB100KB+ (gzipped)
Build stepNoneWebpack/Vite required
State managementServer owns stateClient state (Redux, Zustand)
SEOBuilt-in (server-rendered)Requires SSR/SSG
Learning curveHTML attributesJSX, hooks, lifecycle
Real-time updatesWebSocket + hx-swap-oobWebSocket + state update
Offline supportLimitedService workers
Complex UI interactionsGets awkwardNatural fit
Team skills neededBackend devs can ship UIFrontend specialization
Time to first featureMinutesHours (setup alone)

When to Use HTMX

HTMX shines when:

  • Internal tools and admin panels — nobody cares if it’s React, they care if it works
  • CRUD applications — forms, tables, basic interactions
  • Backend teams shipping UI — no frontend hire needed
  • Prototyping — get something working in minutes
  • Content-heavy sites — blogs, docs, e-commerce

Stick with React/Angular when:

  • Complex client-side state (drag-and-drop, real-time collaboration)
  • Offline-first requirements
  • Heavy animations and transitions
  • Mobile apps via React Native

Common Problems

ProblemCauseSolution
HTMX not firing requestsScript not loadedCheck WebJars path: /webjars/htmx.org/dist/htmx.min.js
Fragment renders full pageWrong view name returnedReturn "fragments/product-row" not "products"
Delete doesn’t remove rowWrong hx-targetUse th:attr="hx-target='#product-' + ${product.id()}" with hx-swap="outerHTML"
Form doesn’t reset after submitMissing reset handlerAdd hx-on::after-request="this.reset()"
CORS errorsHTMX sends different originNot an issue with same-origin (monolithic app)
Thymeleaf caching in devDefault caching enabledSet spring.thymeleaf.cache=false
406 Not AcceptableContent negotiation issueEnsure controller returns view name, not @ResponseBody

Full Working Example

The complete project with all source code is available at:

https://github.com/AnupamSinha/spring-boot-examples/tree/main/29-htmx

Run it:

1
2
3
cd 29-htmx
mvn spring-boot:run
# Open http://localhost:8080/products

You’ll see a product table with:

  • Real-time search (type in the search box)
  • Add products (fill the form, click Add)
  • Delete products (click Delete, confirm)
  • No page reloads at any point

References

This post is licensed under CC BY 4.0 by the author.