Alpine.js & Web Components

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 Alpine.js 3.17.4 (2026-09-21), htmx 2.0.11 (htmx 4.0.0 exists only on the npm next tag), htmx-spring-boot 5.2.0 (not managed by the Spring Boot BOM, so its version is explicit), Web Awesome 3.14.0 (2026-09-24), Thymeleaf 3.1.5.RELEASE, Spring Boot 4.1.1 (Spring Framework 7.0.9) and Java 21, checked on 2026-10-08. The examples were compiled and run in a scratch Bookshelf application (MockMvc tests, plus a real Chromium driven by Playwright for everything that needs JavaScript), and the rendered output shown is the real output of those runs. Alpine and web components run in the browser, so the page applies to Thymeleaf standalone, with Spring MVC and with Spring WebFlux alike: the server only has to deliver HTML that contains the right attributes. The controllers shown are Spring MVC ones, and the Spring Security details (CSRF) are the servlet ones.

Server-rendered pages need two kinds of dynamism. Anything that needs data from the server is a job for htmx (see Fragment Rendering & AJAX): the server renders a fragment and htmx swaps it in. Anything that is purely a matter of the browser (is the menu open, which row is being edited, which book is the confirmation dialog about) does not need a server round trip, and writing a custom script for each of those is tedious. Alpine.js covers that second kind with a handful of HTML attributes, and web components (custom elements with their own markup, style and behaviour) bring ready-made widgets such as trees, dialogs and date pickers. This page shows how both fit with Thymeleaf templates and htmx, how to hand server data to them without opening a script-injection hole, and where the two books this section consulted are outdated (Alpine 2 syntax, Shoelace, and a user’s name interpolated into a JavaScript string).

Who does what

Concern Tool Example in this page

Content that comes from the server, forms, validation, anything that must survive a reload

Thymeleaf renders it, htmx swaps it

The inline-edit row and the lazy tree

State that only exists in the browser and can be lost on reload: open or closed, current tab, which item a dialog is about

Alpine.js (x-data, x-show, $dispatch)

The dropdown menu, the confirmation modal

A rich widget that is already built, accessible and styled

A web component (Web Awesome, a date picker), configured with attributes and properties

wa-tree and duet-date-picker

Letting two of these talk

DOM events: Alpine listens for htmx events, htmx listens for web component events

@htmx:before-request, hx-trigger="wa-lazy-load consume"

The rule that keeps these pages maintainable is locality of behaviour: the attribute that makes something happen sits on the element it affects, and the server stays the source of truth. Alpine state is a convenience, never a place to keep data that matters.

Installing Alpine

Alpine is a single script that must be loaded with defer, so that it starts after the document is parsed. There are three ways to get it into a Spring Boot application: a WebJar (a classpath resource served by Spring Boot under /webjars/), a CDN URL with a pinned version and a Subresource Integrity (SRI) hash, or npm with a bundler (the section’s page on styling and front-end assets covers the bundler side). The WebJar is the simplest for an application that already uses Maven. It gives a pinned, self-hosted, cacheable file:

<dependency>
  <groupId>org.webjars.npm</groupId>
  <artifactId>alpinejs</artifactId>
  <version>3.17.4</version>
</dependency>
<dependency>
  <groupId>org.webjars.npm</groupId>
  <artifactId>htmx.org</artifactId>
  <version>2.0.11</version>
</dependency>

Source: WebJars

The htmx.org version is explicit on purpose: at the time of writing the newest htmx.org WebJar is 4.0.0 (the htmx 4 line), which has a different API. The versions are not managed by the Spring Boot BOM. A version-less URL needs a locator library, so the examples use the versioned path. Because Spring Security protects every URL by default, the static prefixes have to be opened, otherwise the browser receives a redirect to the login page where it expected a script:

@Bean
SecurityFilterChain chain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(a -> a
            .requestMatchers("/login", "/css/**", "/js/**", "/webjars/**").permitAll()
            .anyRequest().authenticated())
        .formLogin(Customizer.withDefaults());
    return http.build();
}

A request to a protected path without a session answered 302 with Location: http://localhost:18433/login, while /webjars/alpinejs/3.17.4/dist/cdn.min.js answered 200 with content type text/javascript.

The CDN alternative pins the exact version and adds an SRI hash. The hashes below were computed from the files of those exact versions on jsDelivr (curl | openssl dgst -sha384 -binary | openssl base64 -A), and the page with these two tags was loaded in Chromium, where Alpine.version reported 3.17.4 and htmx.version reported 2.0.11:

<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.11/dist/htmx.min.js"
        integrity="sha384-2OatzQy1H+Zd/IIrjr1TcuDGqLXeHhbooAyJY1KdQMKnr4LZ22k31GBLdYKHmVjg"
        crossorigin="anonymous"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.17.4/dist/cdn.min.js"
        integrity="sha384-5/joNqFnRyVWzXp99bHot6RHG+EksGp+USSgZwPar7T9SD9PKKER37n/8bXBAZGd"
        crossorigin="anonymous"></script>

The Alpine installation page recommends hard-coding the version in the CDN URL for production (a @3.x.x range pulls whatever is newest), which is also what makes an SRI hash possible.

In a layout, put the scripts and the CSS rule that hides not-yet-initialised elements in one shared fragment, so every page gets the same set. This is the fragment used by every example below; the two meta elements carry the CSRF token for htmx requests (see CSRF for htmx requests):

<th:block th:fragment="assets">
  <meta name="_csrf" th:content="${_csrf.token}">
  <meta name="_csrf_header" th:content="${_csrf.headerName}">
  <style>[x-cloak] { display: none !important; }</style>
  <script th:src="@{/webjars/htmx.org/2.0.11/dist/htmx.min.js}" defer></script>
  <script th:src="@{/webjars/alpinejs/3.17.4/dist/cdn.min.js}" defer></script>
  <script th:src="@{/js/bookshelf.js}" defer></script>
</th:block>

A page includes it inside its <head> with a th:replace on a th:block (see Fragments):

<th:block th:replace="~{alpine/head :: assets}"></th:block>

x-cloak is removed by Alpine when it has initialised an element, and without the CSS rule the hidden parts of a page flash on screen until then.

Alpine’s default build evaluates the expressions in your attributes with the JavaScript Function constructor, which a Content Security Policy without 'unsafe-eval' forbids. Alpine ships a second build for that case (@alpinejs/csp), documented as supporting most of the inline expression syntax; Template Security & XSS covers CSP nonces for the inline scripts used below.

Alpine 3 directives on Thymeleaf templates

Thymeleaf only processes attributes that belong to a dialect it knows (th: and its data-th- form, plus the prefixes of added dialects such as sec:, layout: and hx:), and it copies every other attribute to the output exactly as written. Alpine’s @click, :aria-expanded, x-on:click and x-data therefore pass through unchanged, and an Alpine expression is never evaluated by Thymeleaf. That also means a Thymeleaf expression inside an Alpine attribute is not evaluated: to put server data into one, use a th:* attribute that Thymeleaf evaluates (see Passing server data safely).

A dropdown menu is the classic client-only component. Its state is a single boolean, there is nothing to ask the server, and every part of it is an Alpine directive:

<nav id="user-menu" x-data="{ open: false }" @keydown.escape="open = false" @click.outside="open = false">
  <button type="button" id="menu-button" aria-controls="menu-list"
          :aria-expanded="open" @click="open = !open">Menu</button>
  <ul id="menu-list" x-show="open" x-cloak x-transition.duration.200ms>
    <li><a th:href="@{/alpine/books}">Books</a></li>
    <li><a th:href="@{/alpine/tree}">Shelves</a></li>
  </ul>
</nav>

Thymeleaf evaluated the two th:href attributes and left everything else alone:

<nav id="user-menu" x-data="{ open: false }" @keydown.escape="open = false" @click.outside="open = false">
  <button type="button" id="menu-button" aria-controls="menu-list"
          :aria-expanded="open" @click="open = !open">Menu</button>
  <ul id="menu-list" x-show="open" x-cloak x-transition.duration.200ms>
    <li><a href="/alpine/books">Books</a></li>
    <li><a href="/alpine/tree">Shelves</a></li>
  </ul>
</nav>

In Chromium the list was hidden at first, aria-expanded was false; a click on the button showed the list and set aria-expanded to true; a click anywhere outside the nav, or the Escape key (with focus on the button), hid it again.

The directives used so far, and the ones in the examples that follow:

Directive What it does

x-data

Declares a component and its reactive state, as an inline object or the name of an Alpine.data() component

x-show

Toggles the element’s display. The element stays in the DOM

x-transition

Animates x-show with CSS transitions; modifiers such as .opacity and .duration.300ms tune it (separate x-transition:enter and x-transition:leave forms exist)

x-bind (or the : shorthand)

Sets an attribute from an expression: :aria-expanded="open", x-bind:action="action"

x-on (or the @ shorthand)

Listens for an event; modifiers include .outside, .window, .escape, .camel and .dot

x-init

Runs an expression when the element is initialised

x-text

Sets the element’s text content (never markup), the safe way to print state

x-ref and $refs, $nextTick, $el, $dispatch

Element handles, run-after-render, the current element, and a custom event that bubbles

@click.outside fires for a click that does not originate inside the element it is registered on, and, as the Alpine documentation notes, only while that element is visible. Put it on the element that contains both the button and the list (as above), so that the button is "inside". x-init is the place for one-off setup. The Bookshelf shows a flash message after a delete, and the message removes itself after four seconds:

<p th:if="${message}" th:text="${message}" id="flash" role="status"
   x-data="{ show: true }" x-show="show" x-transition.opacity.duration.300ms
   x-init="setTimeout(() => show = false, 4000)">Deleted</p>

The message text comes from th:text, so a book title with markup in it is escaped. In Chromium the paragraph read Deleted Dune <Messiah> right after the redirect and was hidden after the delay.

Alpine 2 syntax in the books

The first book was written for Alpine 2 (the htmx book already uses Alpine 3). The Alpine upgrade guide lists the changes, and the two that matter in templates written from that book are these:

Alpine 2 Alpine 3 Note

@click.away="open = false"

@click.outside="open = false"

.away was renamed

x-show.transition="open"

x-transition on the element, with x-show="open"

.transition helpers moved to the x-transition directive

Passing server data safely

An x-data attribute is JavaScript. The tempting way to put a server value in it is to build the object literal in the template with string concatenation, and that is the mistake the first book makes with a user’s full name: the name ends up inside a quoted JavaScript string. HTML escaping protects the attribute, but the browser decodes the attribute before Alpine evaluates it, so a name with an apostrophe breaks the string and a crafted name runs code. The Inlining page shows the unsafe output; this page does not repeat the vulnerable template, only the three safe designs, each in a complete component. The Bookshelf profile page receives a reader whose name is deliberately hostile, O'Neil'}; alert(1); //:

<!-- 1. A scalar in a data-* attribute: read through $el.dataset -->
<p id="greeting" x-data="{ name: $el.dataset.name }" th:data-name="${reader.fullName}">
  Hello, <span x-text="name">Ada</span>
</p>

<!-- 2. A component registered with Alpine.data(); the values come from JavaScript inlining -->
<div id="stats" x-data="profile">
  <p><span x-text="name"></span> has read <span x-text="booksRead"></span> books.</p>
  <button type="button" @click="booksRead++">I finished another</button>
</div>
<script th:inline="javascript">
  document.addEventListener('alpine:init', () => {
    Alpine.data('profile', () => ({
      name: /*[[${reader.fullName}]]*/ 'Ada Lovelace',
      booksRead: /*[[${reader.booksRead}]]*/ 0
    }));
  });
</script>

<!-- 3. A larger structure as a JSON data island -->
<script type="application/json" id="reader-data" th:inline="javascript">/*[[${reader}]]*/ {}</script>
<div id="island" x-data="{ reader: JSON.parse(document.getElementById('reader-data').textContent) }">
  <span x-text="reader.fullName"></span>
</div>

The controller passes new Reader(name, 42) (a record with fullName and booksRead) as the model attribute reader. In a real application the name comes from the signed-in account, never from a request parameter, which this scratch controller uses only to feed in the hostile text. Rendered (the three data paths only):

<p id="greeting" x-data="{ name: $el.dataset.name }" data-name="O&#39;Neil&#39;}; alert(1); //">
  Hello, <span x-text="name">Ada</span>
</p>
...
<script>
  document.addEventListener('alpine:init', () => {
    Alpine.data('profile', () => ({
      name: "O'Neil'}; alert(1); \/\/",
      booksRead: 42
    }));
  });
</script>
...
<script type="application/json" id="reader-data">{"fullName":"O'Neil'}; alert(1); \/\/","booksRead":42}</script>

In Chromium all three components printed the name exactly as typed (O'Neil'}; alert(1); //), the button incremented booksRead from 42 to 43, no dialog was opened, and no script error was logged. Why each one is safe:

  • The data-* value is only HTML-escaped, which is enough because it is never evaluated: Alpine reads it as a string from $el.dataset.

  • JavaScript inlining writes a real JavaScript literal (a double-quoted string with the / escaped as \/, numbers as numbers), so the value cannot leave its literal. The comment form /*[[...]]*/ 'default' keeps the template natural.

  • The data island is JSON inside an element the browser does not execute, and the <\/ rewriting means a value cannot close the element.

Booleans and numbers can travel through a data-* attribute too, and the component converts them (every attribute value is a string). The inline-edit row below does x-data="{ editing: $el.dataset.editing === 'true' }". With a strict CSP, an inline <script> needs a nonce in each of the three variants.

A confirmation modal that sets the form action

Deleting is a state change, so it is a POST (or DELETE through a hidden method field), never a link. The old pattern from the first book is a single modal for the page: each row’s button announces which book it is about, the modal remembers it, and the modal’s form posts to the right URL. The row button carries the title and the URL in data-* attributes (evaluated by Thymeleaf and escaped), and sends them in a custom event:

<button type="button" class="delete" x-show="!editing"
        th:data-title="${book.title}"
        th:data-action="@{/alpine/books/{id}/delete(id=${book.id})}"
        @click="$dispatch('confirm-delete', { title: $el.dataset.title, action: $el.dataset.action })">Delete</button>

The modal, once per page, listens on the window for that event:

<div x-data="{ open: false, title: '', action: '' }"
     @confirm-delete.window="title = $event.detail.title; action = $event.detail.action; open = true"
     @keydown.escape.window="open = false"
     x-show="open" x-cloak x-transition.opacity
     role="dialog" aria-modal="true" aria-labelledby="confirm-title" id="confirm">
  <div @click.outside="open = false">
    <h2 id="confirm-title">Delete this book?</h2>
    <p x-text="title"></p>
    <form method="post" x-bind:action="action">
      <input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}">
      <button type="button" @click="open = false">Cancel</button>
      <button type="submit" id="confirm-yes">Delete</button>
    </form>
  </div>
</div>

The detail that decides whether this works is the hidden input. When a form has th:action, Thymeleaf with Spring Security adds a hidden _csrf field to it by itself (you can see it in the inline-edit form below). The modal form’s action is set later by Alpine in the browser, so Thymeleaf never sees an action to attach the token to, and without the field every submission is rejected with 403. The token is written by hand from the _csrf request attribute (${_csrf.parameterName} and ${_csrf.token}), which is exactly what the field would have contained. The controller is an ordinary post-redirect-get handler with a flash message:

@PostMapping("/{id}/delete")
String delete(@PathVariable Long id, RedirectAttributes redirect) {
    Book book = find(id);
    books.delete(book);
    redirect.addFlashAttribute("message", "Deleted " + book.getTitle());
    return "redirect:/alpine/books";
}

The MockMvc test shows the token is required, and that the delete works with it:

@Test
void deleteNeedsCsrfToken() throws Exception {
    mvc.perform(post("/alpine/books/" + dune + "/delete")).andExpect(status().isForbidden());
    assertThat(repo.count()).isEqualTo(1);
    mvc.perform(post("/alpine/books/" + dune + "/delete").with(csrf()))
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrl("/alpine/books"))
            .andExpect(flash().attribute("message", "Deleted Dune"));
    assertThat(repo.count()).isZero();
}

In Chromium the modal was hidden initially; a click on a row’s Delete button showed it, with the book’s title in the paragraph and the form’s action attribute set to /alpine/books/2/delete; Escape, a click outside the panel and Cancel each hid it; Delete submitted the form, the browser followed the redirect, the flash message appeared and the row was gone. The modal was not closed by the very click that opened it, because .outside is only evaluated while the element is visible.

Combining Alpine with htmx

The two libraries do not overlap and do not conflict: htmx owns the conversation with the server, Alpine owns the temporary state of the page. They meet in three places.

Alpine listens for htmx events

htmx fires its events in both camel case (htmx:beforeRequest) and kebab case (htmx:before-request); the htmx documentation says explicitly that this is for libraries such as Alpine, which can only listen for names that survive an HTML attribute (attribute names are case-insensitive). The events bubble, so a wrapper element can observe all the requests made from the elements inside it:

<div x-data="{ saving: false }"
     @htmx:before-request="saving = true"
     @htmx:after-request="saving = false">
  <p x-show="saving" x-cloak id="saving">Saving...</p>
  <table>
    ...
  </table>
</div>

With the response delayed by 600 ms the "Saving…​" paragraph was visible while the request was in flight and hidden afterwards. For a custom event with an uppercase letter (such as customEvent) Alpine’s .camel modifier does the conversion: @custom-event.camel="..." listens for customEvent.

CSRF for htmx requests

htmx sends the values of the enclosing form with a request, so a form rendered with th:action already carries its hidden _csrf field. For requests from elements that are not in a form, and for PUT and DELETE, send the token as a header. The two meta elements of the shared fragment hold the token and the header name, and one listener adds them to every request (this is the whole of bookshelf.js):

document.addEventListener('htmx:configRequest', (event) => {
  const token = document.querySelector('meta[name="_csrf"]')?.content;
  const header = document.querySelector('meta[name="_csrf_header"]')?.content;
  if (token && header) {
    event.detail.headers[header] = token;
  }
});

The token goes in a header, not in the URL. The hx:post, hx:put, hx:patch and hx:delete attributes of the hx: dialect in htmx-spring-boot-thymeleaf 5.x inject the token through hx-headers by themselves, according to the library’s README (not exercised on this page; see htmx with Spring Boot), and Spring Security Integration covers the token itself. A request without the token is refused: the test below posts with the HX-Request header and no token and gets 403.

@Test
void htmxPostWithoutTokenIsRejected() throws Exception {
    mvc.perform(post("/alpine/books/" + dune + "/title").header("HX-Request", "true").param("title", "x"))
            .andExpect(status().isForbidden());
}

Inline editing: Alpine for the form, htmx for the save

Inline editing is the standard example of both libraries in one element. Showing and hiding the edit form needs no server, so Alpine does it. Saving needs the server (validation, persistence), so htmx posts the form and swaps the row. This is the whole row, a fragment in its own template, with the Alpine and htmx attributes side by side:

<tr th:fragment="row" th:id="|book-${book.id}|"
    x-data="{ editing: $el.dataset.editing === 'true' }" th:data-editing="${editing == true}">
  <td>
    <span x-show="!editing" th:text="${book.title}">Dune</span>
    <form x-show="editing" x-cloak method="post"
          th:action="@{/alpine/books/{id}/title(id=${book.id})}"
          th:hx-post="@{/alpine/books/{id}/title(id=${book.id})}"
          hx-target="closest tr" hx-swap="outerHTML">
      <input name="title" th:value="${book.title}" x-ref="title" required>
      <span th:if="${error}" th:text="${error}" role="alert">Error</span>
      <button type="submit">Save</button>
      <button type="button" @click="editing = false">Cancel</button>
    </form>
  </td>
  <td th:text="${book.author}">Frank Herbert</td>
  <td>
    <button type="button" x-show="!editing"
            @click="editing = true; $nextTick(() => $refs.title.focus())">Edit</button>
    ...the Delete button shown above...
  </td>
</tr>

The controller has two methods for the same URL. The htmx one is selected by @HxRequest of htmx-spring-boot (it matches only requests that carry the HX-Request header) and answers with the row. The plain one is the fallback for a browser without JavaScript and redirects as any form post does:

// htmx request: answer with the one table row
@HxRequest
@PostMapping("/{id}/title")
String renameFragment(@PathVariable Long id, @RequestParam String title, Model model) {
    Book book = find(id);
    if (title.isBlank()) {
        model.addAttribute("book", book);
        model.addAttribute("editing", true);
        model.addAttribute("error", "The title must not be blank.");
    } else {
        book.setTitle(title.strip());
        model.addAttribute("book", books.save(book));
    }
    return "alpine/book-row :: row";
}

// plain form post (no JavaScript): Post/Redirect/Get
@PostMapping("/{id}/title")
String rename(@PathVariable Long id, @RequestParam String title) {
    Book book = find(id);
    if (!title.isBlank()) {
        book.setTitle(title.strip());
        books.save(book);
    }
    return "redirect:/alpine/books";
}

The view name is a constant in the code. It must never be built from request input (see Template Security & XSS). @HxRequest is the 5.2.0 package io.github.wimdeblauwe.htmx.spring.boot.mvc. The rendered response for a valid rename was only the row, with the CSRF token shortened to TOKEN:

<tr id="book-10"
    x-data="{ editing: $el.dataset.editing === 'true' }" data-editing="false">
  <td>
    <span x-show="!editing">Dune Messiah</span>
    <form x-show="editing" x-cloak method="post"
          action="/alpine/books/10/title"
          hx-post="/alpine/books/10/title"
          hx-target="closest tr" hx-swap="outerHTML"><input type="hidden" name="_csrf" value="TOKEN"/>
      <input name="title" value="Dune Messiah" x-ref="title" required>
      ...

For a blank title the same fragment came back with data-editing="true" and <span role="alert">The title must not be blank.</span>, so the row is still in edit mode with the error beside the field. The test asserts the fragment starts with <tr id="book-... and contains no <html or <table. In Chromium, Edit showed the input and focused it, Cancel hid it, a blank save left the row editing with the error, and a valid save replaced the row with the new title. A title containing <Messiah> was printed as text, because both the span and the input value use th:text/th:value.

What happens to Alpine state when htmx swaps

Two things to know, both observed in the browser:

  • New Alpine components are initialised automatically. After the swap above the new <tr> was a working component (Edit opened the input again). Alpine watches the DOM for added elements and initialises them, so you do not call anything after an htmx swap. Alpine.initTree(el) is exposed on the Alpine object in 3.17.4, but the globals documented on alpinejs.dev are Alpine.data, Alpine.store and Alpine.bind, so treat it as an implementation detail.

  • The state of a swapped component is lost, because htmx replaced the element with a new one. That is what a row wants (editing is false again after saving). When an entire Alpine component is swapped and its state must survive, htmx has an extension, alpine-morph (npm package htmx-ext-alpine-morph, with Alpine’s @alpinejs/morph plugin), used with hx-ext="alpine-morph" and hx-swap="morph". It is described in the extension’s README; this page did not run it. Often it is simpler to swap a smaller region than to preserve state.

The opposite direction needs one call. htmx initialises elements that are in the document or arrive through a swap. Markup that Alpine creates later from a <template x-if> or x-for is not seen by htmx, so after Alpine inserts it, call htmx.process(element). The htmx documentation shows exactly this with Alpine’s $watch.

Web components

A web component is a custom HTML element (<wa-button>, <duet-date-picker>) that carries its own markup, style and behaviour, usually in a shadow DOM. They work in any server-rendered page because the "API" is attributes, properties and DOM events, which is also what htmx and Alpine use. Three points matter for Thymeleaf:

  • A Thymeleaf template renders the custom element’s tag and attributes like any other markup, and th:* attributes work on it.

  • Attributes carry strings only. Objects and functions (such as the date picker’s localisation) are properties, set from JavaScript.

  • A component’s events are DOM events, so they can be htmx triggers and Alpine listeners.

Shoelace and Web Awesome

The first generation of the library the htmx book uses, Shoelace, has been replaced. As of 2026-10-08 the Shoelace site states that it is sunset with no active development, its last release is 2.20.1 (2025-03-11), and it tells you to use Web Awesome for ongoing work. Web Awesome is published as @awesome.me/webawesome (3.14.0, 2026-09-24, MIT licence on npm) and as the WebJar org.webjars.npm:awesome.me__webawesome:3.14.0.

Shoelace 2 (sunset) Web Awesome 3

npm package

@shoelace-style/shoelace

@awesome.me/webawesome

Element prefix

sl- (sl-tree-item)

wa- (wa-tree-item)

Event prefix

sl-lazy-load

wa-lazy-load

CSS custom properties

--sl-*

--wa-*

The migration guide warns that event listener strings are the highest-risk change: a listener for sl-show that is not renamed never fires and nothing throws. That is exactly the situation for hx-trigger="sl-lazy-load ..." copied from the book, and the symptom is a tree that never loads. Some components are paid, not free: Web Awesome’s component index marks Date Picker, Date Input and Combobox as Pro, which is one reason the date picker below is a different library.

The WebJar puts the self-contained CDN build under dist-cdn, which is what the loader script in the example uses; it registers components on demand as it finds them in the page:

<link rel="stylesheet" th:href="@{/webjars/awesome.me__webawesome/3.14.0/dist-cdn/styles/webawesome.css}">
<script type="module" th:src="@{/webjars/awesome.me__webawesome/3.14.0/dist-cdn/webawesome.loader.js}"></script>

A SRI hash is not practical for the loader, because it fetches component modules after it starts. Self-hosting through the WebJar, as above, keeps everything under your own origin.

A lazy tree loaded with htmx

A tree with lazily loaded branches is the case the htmx book uses to show a web component driving htmx. In Web Awesome a wa-tree-item with the lazy attribute emits wa-lazy-load when the user expands it; the documentation says to load the data, append the new items and remove lazy. htmx does the loading when the event is the trigger. The Bookshelf shelves are a small tree, and one fragment renders the children of any node:

<wa-tree id="shelves">
  <th:block th:fragment="items">
    <th:block th:each="node : ${nodes}">
      <wa-tree-item th:if="${node.leaf()}" th:text="${node.name()}">Dune</wa-tree-item>
      <wa-tree-item th:unless="${node.leaf()}" lazy
                    th:hx-get="@{/alpine/tree/{id}(id=${node.id()})}"
                    hx-trigger="wa-lazy-load consume"
                    hx-swap="beforeend"
                    hx-on::after-swap="this.lazy = false">
        <span th:text="${node.name()}">Science fiction</span>
      </wa-tree-item>
    </th:block>
  </th:block>
</wa-tree>
@GetMapping("/alpine/tree/{id}")
String children(@PathVariable long id, Model model) {
    Node node = BY_ID.get(id);
    if (node == null) {
        throw new ResponseStatusException(HttpStatus.NOT_FOUND);
    }
    model.addAttribute("nodes", node.children());
    return "alpine/tree :: items";
}

The node ids come from a fixed in-memory catalogue and the view name is a constant; the id only selects a node and is never part of a template or fragment name. The first response (/alpine/tree/11, the children of "Dune saga") was:

<wa-tree-item>Dune</wa-tree-item>
<wa-tree-item>Dune Messiah &lt;draft&gt;</wa-tree-item>

How the pieces fit:

  • hx-trigger="wa-lazy-load consume" makes the custom event a trigger. By default the target is the element itself, and hx-swap="beforeend" appends the returned items as its children.

  • hx-on::after-swap is the shorthand for the htmx:afterSwap event handler. It removes lazy once the items are in, so the loading state ends and a later expand just shows the children. Removing it after a request that failed would leave the item looking loaded, which is why it is tied to the swap and not to the end of the request.

  • consume is for nested lazy items. The component’s event bubbles, so expanding a lazy child also reaches the lazy parent’s trigger. The htmx documentation defines consume as "the event will not trigger any other htmx requests on parents". In Chromium, expanding "Science fiction" requested /alpine/tree/1, and expanding its lazy child "Dune saga" afterwards requested only /alpine/tree/11. With consume removed from the markup (response rewritten in the test), the same click requested /alpine/tree/11 and then /alpine/tree/1 again.

  • The names are text: the <draft> in a title is escaped by th:text, and the tree shows it literally.

Shadow DOM and htmx.process

By default htmx does not look inside a shadow DOM. A component that renders hx-* attributes inside its own shadow root has to tell htmx about it with htmx.process. This is the pattern from the htmx web components example; the page below is a complete example, served as a static file:

<script>
  customElements.define('book-ping', class extends HTMLElement {
    connectedCallback() {
      const root = this.attachShadow({ mode: 'open' });
      root.innerHTML = `
        <button hx-get="/alpine/ping" hx-target="next output">Ping</button>
        <output></output>`;
      htmx.process(root); // tell htmx about this component's shadow DOM
    }
  });
</script>
<book-ping></book-ping>

With the call, a click on the button put pong into the output element; with the htmx.process(root) line removed the click did nothing and the output stayed empty. Selectors in hx-target only see the same shadow root; the htmx documentation lists the host and global prefixes for reaching outside it. Components without a shadow DOM also need htmx.process when their content is created by script.

Initialising components in swapped content with htmx.onLoad

Some libraries need initialisation code for each element: set a property, register a function. htmx.onLoad(callback) registers a callback for the htmx:load event, which fires for the initial page and for every piece of content htmx inserts, and passes the new content so that only it is searched. The pattern replaces DOMContentLoaded scripts that would otherwise miss everything fetched later.

A date picker with a locale

Browsers have a native <input type="date">, and it is the right first choice (no dependency, localised by the browser). A date-picker component is worth it when the design or the keyboard behaviour requires it. The first book uses the Duet Date Picker, an open-source web component. Its npm package @duetds/date-picker is at 1.4.0 (released 2021-06-18, MIT, no later releases), also available as the WebJar org.webjars.npm:duetds__date-picker:1.4.0. A package that old is a maintenance decision for the project; the pattern below applies to any component that takes its localisation as a JavaScript property.

The component takes the date as an ISO value (YYYY-MM-DD) and gets its labels, day and month names and date format from two properties, localization and dateAdapter. Everything locale-specific should come from the same place the rest of the page’s text comes from: the Spring LocaleResolver and the message bundle. The controller builds the complete localization object (the component requires all keys) from the message bundle and java.time, using the request locale:

@GetMapping("/alpine/dates")
String form(Model model, Locale locale) {
    model.addAttribute("published", LocalDate.of(2026, 10, 8));
    model.addAttribute("duet", localization(locale));
    return "alpine/dates";
}

Map<String, Object> localization(Locale locale) {
    Map<String, Object> map = new LinkedHashMap<>();
    for (String key : List.of("buttonLabel", "placeholder", "selectedDateMessage", "prevMonthLabel",
            "nextMonthLabel", "monthSelectLabel", "yearSelectLabel", "closeLabel", "calendarHeading")) {
        map.put(key, messages.getMessage("datepicker." + key, null, locale));
    }
    List<String> days = new ArrayList<>();
    days.add(DayOfWeek.SUNDAY.getDisplayName(TextStyle.FULL, locale));
    for (DayOfWeek d : DayOfWeek.values()) {
        if (d != DayOfWeek.SUNDAY) {
            days.add(d.getDisplayName(TextStyle.FULL, locale));
        }
    }
    map.put("dayNames", days);
    map.put("monthNames", Arrays.stream(Month.values())
            .map(m -> m.getDisplayName(TextStyle.FULL_STANDALONE, locale)).toList());
    map.put("monthNamesShort", Arrays.stream(Month.values())
            .map(m -> m.getDisplayName(TextStyle.SHORT_STANDALONE, locale)).toList());
    map.put("locale", locale.toLanguageTag());
    return map;
}

The template renders the component, sets the document language from the request locale, and hands the object over as a JSON data island (the safe route from above):

<html th:lang="${#locale.toLanguageTag()}" lang="en" xmlns:th="http://www.thymeleaf.org">
...
<form method="post" th:action="@{/alpine/dates}">
  <label for="published">Published on</label>
  <duet-date-picker identifier="published" name="published" th:value="${published}"></duet-date-picker>
  <div id="extra-dates"></div>
  <button type="button" id="add-date" hx-get="/alpine/dates/field?n=2" hx-target="#extra-dates"
          hx-swap="beforeend">Add another date</button>
  <button type="submit">Save</button>
</form>
<script type="application/json" id="duet-localization" th:inline="javascript">/*[[${duet}]]*/ {}</script>

One script applies the localisation to every picker in the loaded content. The format and the day-month-year order come from Intl.DateTimeFormat for the same locale, and htmx.onLoad runs it for the initial page and for pickers that htmx adds (the "Add another date" button):

function localise(picker, localization) {
  const locale = localization.locale;
  const format = new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' });
  const order = format.formatToParts(new Date(Date.UTC(2001, 10, 23)))
    .filter((part) => ['day', 'month', 'year'].includes(part.type)).map((part) => part.type);
  picker.localization = localization;
  picker.dateAdapter = {
    parse(value = '', createDate) {
      const numbers = value.match(/\d+/g);
      if (!numbers || numbers.length !== 3) {
        return undefined;
      }
      const parts = Object.fromEntries(order.map((type, index) => [type, numbers[index]]));
      return createDate(parts.year, parts.month, parts.day);
    },
    format(date) {
      return format.format(new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())));
    }
  };
}

htmx.onLoad((content) => {
  const island = document.getElementById('duet-localization');
  if (!island) {
    return;
  }
  const localization = JSON.parse(island.textContent);
  const pickers = content.matches?.('duet-date-picker') ? [content] : [];
  pickers.push(...content.querySelectorAll('duet-date-picker'));
  pickers.forEach((picker) => localise(picker, localization));
});

In Chromium with the browser locale es-ES (the server answered with lang="es-ES" and the Spanish messages) the input showed 08/10/2026 with the placeholder DD/MM/AAAA, the toggle’s label was "Elegir fecha, La fecha seleccionada es 8 de octubre de 2026", and the month list started with enero. With en-US it showed 10/08/2026, the placeholder MM/DD/YYYY and "Choose date, Selected date is October 8, 2026". The form submitted published=2026-10-08 in both, and after typing 15/11/2026 (Spanish) or 11/15/2026 (US) it submitted published=2026-11-15. The component writes an ISO value into the form control, so the server can bind it with @DateTimeFormat(iso = ISO.DATE) and does not depend on the browser’s display format. A picker added by htmx ("Add another date") was localised by the same htmx.onLoad callback: its localization.buttonLabel was "Elegir fecha" and its dateAdapter.parse was a function.

@Test
void datePickerLocalisationFollowsTheRequestLocale() throws Exception {
    String es = body(get("/alpine/dates").locale(Locale.forLanguageTag("es-ES")));
    assertThat(es).contains("<html lang=\"es-ES\"", "\"buttonLabel\":\"Elegir fecha\"",
            "\"dayNames\":[\"domingo\",\"lunes\"", "\"locale\":\"es-ES\"", "value=\"2026-10-08\"");
    String en = body(get("/alpine/dates").locale(Locale.US));
    assertThat(en).contains("\"buttonLabel\":\"Choose date\"", "\"dayNames\":[\"Sunday\",\"Monday\"");
}

One Spring Boot detail showed up here. With the default spring.messages.fallback-to-system-locale=true, a request for a locale that has no bundle (English, when only messages.properties and messages_es.properties exist) falls back to the JVM’s default locale before the base bundle, so on a Spanish server an English visitor got the Spanish labels. Setting the property to false makes the base bundle the fallback, and the test above then passes on any machine. See the Internationalization page for the message bundle side.

Markdown from outside, rendered as HTML

The htmx book renders Markdown (release notes fetched from a third-party feed) to HTML and prints it with th:utext. th:utext writes the value as markup without escaping, so whatever the Markdown can express as HTML (raw tags, script elements, event-handler attributes, javascript: links) becomes live in your page, and the text is not yours. A Markdown renderer is not a sanitiser: CommonMark allows raw HTML in a document, and the library’s own documentation says the caller is responsible for sanitising the result if it is exposed. Do it in layers, in application code and before the model:

<dependency>
  <groupId>org.commonmark</groupId>
  <artifactId>commonmark</artifactId>
  <version>0.30.0</version>
</dependency>
<dependency>
  <groupId>com.googlecode.owasp-java-html-sanitizer</groupId>
  <artifactId>owasp-java-html-sanitizer</artifactId>
  <version>20260924.2</version>
</dependency>

Source: commonmark-java

@Component
class MarkdownRenderer {

    private static final PolicyFactory POLICY = new HtmlPolicyBuilder()
            .allowElements("p", "em", "strong", "ul", "ol", "li", "code", "pre", "h2", "h3", "a")
            .allowUrlProtocols("https")
            .allowAttributes("href").onElements("a")
            .requireRelNofollowOnLinks()
            .toFactory();

    private final Parser parser = Parser.builder().build();
    private final HtmlRenderer renderer = HtmlRenderer.builder()
            .escapeHtml(true)
            .sanitizeUrls(true)
            .build();

    String toSafeHtml(String markdown) {
        return POLICY.sanitize(renderer.render(parser.parse(markdown)));
    }
}

escapeHtml(true) turns raw HTML in the Markdown into text, sanitizeUrls(true) drops unsafe link targets, and the allow-list policy removes anything else, so a change to the renderer settings cannot silently open the page. The template is the only place that prints the result unescaped, and it says why:

<!-- th:utext is acceptable here only because MarkdownRenderer escapes and sanitises first -->
<div id="notes" th:utext="${notes}">Notes</div>

The fixture was a feed body with a heading, a bullet list with a bold word and two links (one https: and one with the javascript: scheme), and a raw script element. The rendered result kept the heading, the list, the bold word and the https link with rel="nofollow", left the javascript: link without a target, and printed the script element as text:

<div id="notes"><h2>2.1.0</h2>
<ul><li><strong>Search</strong> is faster</li><li>See the <a href="https://example.com/changelog" rel="nofollow">changelog</a> and <a href="" rel="nofollow">this</a></li></ul>
<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>
</div>

Source: commonmark-java

The test also renders the same fixture with a default HtmlRenderer (no options) and asserts that the output contains the script element and the javascript: link, which is why the default renderer plus th:utext must never be used for content you do not control. The Template Security & XSS page covers when th:utext is acceptable and the sanitiser policy in more depth.

Pitfalls

Symptom Cause Fix

The menu never closes on an outside click, or the modal has no animation

Alpine 2 syntax copied from the books: @click.away, x-show.transition

Use @click.outside and x-transition; follow the Alpine 3 upgrade guide

Content flashes on load and hidden panels are visible for a moment

x-cloak is set but the CSS rule [x-cloak] { display: none !important; } is missing

Add the rule to the shared head fragment

The Alpine or htmx script is a login page, and nothing works

/webjars/** and /js/** are not permitted in the Spring Security configuration (the request is answered with 302 to /login)

Permit the static prefixes in authorizeHttpRequests

A name with an apostrophe breaks the component, or script runs

A server value was concatenated into an x-data string

Use th:data-*, Alpine.data with th:inline="javascript", or a JSON data island

Submitting the modal form returns 403

x-bind:action is set in the browser, so Thymeleaf added no _csrf field

Add the hidden input by hand from ${_csrf.parameterName} and ${_csrf.token}

An htmx PUT or DELETE returns 403

No token header, and no enclosing form to send the hidden field

Add the token header in htmx:configRequest from meta elements (never in the URL)

@htmx:afterRequest never fires

HTML attribute names are case-insensitive, so the camel-case event name cannot be matched

Use the kebab-case name (@htmx:after-request) or the .camel modifier

The edit state or the open menu is gone after a swap

htmx replaced the element, so Alpine created a new component with fresh state

Swap a smaller region, keep the state on an element outside the swap, or use alpine-morph

hx-get inside an x-if template or a shadow root does nothing

htmx does not see markup created after load or inside a shadow DOM

Call htmx.process(element) after inserting it, and htmx.process(root) in the component

A parent lazy item reloads when a child is expanded

The wa-lazy-load event bubbles to the parent’s trigger

Add consume to the trigger: hx-trigger="wa-lazy-load consume"

A migrated tree never loads

hx-trigger="sl-lazy-load" copied from a Shoelace example, while the event is wa-lazy-load in Web Awesome

Rename every sl- element, event and custom property to wa-

An English visitor sees Spanish labels

spring.messages.fallback-to-system-locale defaults to true, so the JVM default locale wins over the base bundle

Set it to false, or provide a bundle for every locale

A returned fragment is an empty 200

The view name selects a fragment that does not exist (here, after the fragment moved to another template)

Check the template name and the fragment name, and cover each fragment response with a test

Method leaf() cannot be found (EL1004E) for a record that declares the method

The method was package-private; only public methods (such as the record’s accessors) can be called from SpEL

Declare the method public, and keep the type visible to the template engine

Release notes or user Markdown render with live scripts or javascript: links

th:utext over the output of a renderer that allows raw HTML

Escape raw HTML, strip unsafe URLs, run an allow-list sanitiser, then use th:utext