diff --git a/README.md b/README.md
index c21d707..48345b7 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ files.
| Directory | Article | What it demonstrates |
|---|---|---|
+| [`thymeleaf-htmx/`](thymeleaf-htmx) | [Spring Boot 4 + Thymeleaf + htmx: Server-Side CRUD UI in 2026](https://ankurm.com/spring-boot-thymeleaf-htmx-crud-ui/) | one URL answering as full page or fragment on `HX-Request`, a Thymeleaf layout, 422 validation re-render, pagination, inline edit and delete, 10 MockMvc tests plus a headless-Chromium check that observed htmx 2 ignoring a 204 and (without a config tag) a 422 |
| [`sbom/`](sbom) | [Spring Boot SBOM in Practice: the tools jar-mode sbom Command, CycloneDX and Dependency Scanning](https://ankurm.com/spring-boot-sbom-tools-jarmode-cyclonedx-dependency-scanning/) | the 4.2.0-RC1 `java -Djarmode=tools -jar app.jar sbom` command (absent in 4.1.2), the CycloneDX document the build puts in the jar, `/actuator/sbom`, an SBOM that lists 8 starters not in the jar and misses the jar mode's own library, a licence rule that wrongly flags dual-licensed logback, and an OSV scan that finds Log4Shell in a deliberately old build |
| [`hexagonal/`](hexagonal) | [Hexagonal Architecture (Ports and Adapters) in Spring Boot 4](https://ankurm.com/hexagonal-architecture-ports-and-adapters-spring-boot-4/) | a 4-module Maven reactor that makes "the core has no Spring dependency" something `mvn dependency:tree` proves rather than a naming convention, testing the core with zero Spring context, and a Spring Boot 4 test-slice-artifact split (`@DataJpaTest`, `@WebMvcTest`, `TestRestTemplate`) hit and fixed along the way |
| [`actuator-in-production/`](actuator-in-production) | [Spring Boot Actuator in Production](https://ankurm.com/spring-boot-actuator-production-endpoints-security-health-indicators/) | endpoint exposure defaults, securing Actuator, custom health indicators and how they hang |
diff --git a/thymeleaf-htmx/.gitignore b/thymeleaf-htmx/.gitignore
new file mode 100644
index 0000000..3b56ea5
--- /dev/null
+++ b/thymeleaf-htmx/.gitignore
@@ -0,0 +1,3 @@
+target/
+browser-check/node_modules/
+browser-check/package-lock.json
diff --git a/thymeleaf-htmx/README.md b/thymeleaf-htmx/README.md
new file mode 100644
index 0000000..f43c2ec
--- /dev/null
+++ b/thymeleaf-htmx/README.md
@@ -0,0 +1,60 @@
+# thymeleaf-htmx
+
+Companion project for **Spring Boot 4 + Thymeleaf + htmx: Server-Side CRUD UI in 2026** on **[ankurm.com](https://ankurm.com)**.
+
+A contacts list with search, pagination, create-with-validation, inline edit and delete. All HTML is rendered by Thymeleaf; htmx only decides *which part* of a page to replace. About 25 lines of JavaScript-free attributes, no JS framework, no build step.
+
+Every console block in the article is quoted from `output/`. The MockMvc transcripts are written by the tests themselves (they assert on the same data), and `output/11-browser-check.txt` comes from headless Chromium driving the packaged jar.
+
+## Versions
+
+| | |
+|---|---|
+| Spring Boot | 4.1.2 (latest GA on Maven Central; 4.2.0-RC1 exists but is not GA) |
+| Thymeleaf | 3.1.5.RELEASE (managed by Boot) |
+| htmx | 2.0.11 as the WebJar `org.webjars.npm:htmx.org` (htmx 4.0.0 exists on npm `next`; see the article) |
+| JDK | 25 |
+| Test tooling | `spring-boot-starter-webmvc-test`, `spring-boot-starter-thymeleaf-test`; Playwright 1.64.1 for the browser check (optional) |
+
+## Run
+
+```bash
+export JAVA_HOME=/path/to/jdk-25
+mvn spring-boot:run # http://localhost:8080/contacts
+mvn test # 10 MockMvc tests, rewrite output/01..09
+./scripts/run-all.sh # tests + live curl + browser check, regenerates all of output/
+```
+
+The browser check needs Node 20+ and a one-time `npx playwright install chromium`.
+
+## Where things are
+
+| Concern | File |
+|---|---|
+| Layout fragment (`layout(title, content)`), htmx config meta tag, WebJar script tag | `templates/layout/base.html` |
+| List page + the `table` fragment htmx receives | `templates/contacts/list.html` |
+| Row, edit-row (also the error state) | `templates/contacts/fragments.html` |
+| Create form (full page and `form` fragment) | `templates/contacts/new.html` |
+| Fragment vs full page decision (`HX-Request`, `HX-History-Restore-Request`) | `Htmx.java`, `ContactController.java` |
+| `Vary` header | `VaryHtmxFilter.java` |
+| Contract tests | `ContactUiTest.java`, `Transcript.java` |
+| Real-browser behaviour checks | `browser-check/check.mjs` |
+
+## Output
+
+| File | Shows |
+|---|---|
+| `output/00-mvn-test.txt` | surefire summary |
+| `output/01-full-page-vs-fragment.txt` | same URL, with and without `HX-Request` |
+| `output/02-history-restore-gets-full-page.txt` | the history-miss case |
+| `output/03-htmx-webjar.txt` | version-less WebJar path resolved to the pinned version |
+| `output/04-pagination-and-search.txt` | last page, page past the end, search |
+| `output/05-create-validation-errors.txt` | 422 with errors, fragment and full page |
+| `output/06-create-success.txt` | `HX-Redirect` vs 302 |
+| `output/07-inline-edit.txt` | edit row, failed save, successful save |
+| `output/08-delete.txt` | 200 + `HX-Trigger`, 404 |
+| `output/09-delete-last-page-clamps.txt` | pager after deleting the last page's rows |
+| `output/10-live-curl.txt` | real Tomcat answering with curl |
+| `output/11-browser-check.txt` | Chromium: swaps, history, 204 and 422 traps |
+
+Not covered: Spring Security / CSRF (no Security dependency in this module), file uploads, and htmx 4.
diff --git a/thymeleaf-htmx/browser-check/check.mjs b/thymeleaf-htmx/browser-check/check.mjs
new file mode 100644
index 0000000..52e96a9
--- /dev/null
+++ b/thymeleaf-htmx/browser-check/check.mjs
@@ -0,0 +1,116 @@
+// Drives the running app in headless Chromium so the htmx behaviour claims are observed, not assumed.
+// usage: node check.mjs http://localhost:18083
+import { chromium } from 'playwright';
+
+const base = process.argv[2] ?? 'http://localhost:18083';
+const out = [];
+const log = (s) => { out.push(s); console.log(s); };
+const rows = (page) => page.locator('#contact-table tbody tr[id]');
+const names = async (page) => (await rows(page).locator('td:first-child').allTextContents()).map(s => s.trim());
+
+const browser = await chromium.launch();
+const ctx = await browser.newContext();
+const page = await ctx.newPage();
+const requests = [];
+page.on('request', r => { if (r.url().startsWith(base) && !r.url().includes('/webjars/') && !r.url().endsWith('.css'))
+ requests.push(`${r.method()} ${r.url().replace(base, '')} HX-Request=${r.headers()['hx-request'] ?? '-'} HX-History-Restore-Request=${r.headers()['hx-history-restore-request'] ?? '-'}`); });
+
+log('== 1. load, then search without a page reload');
+await page.goto(base + '/contacts');
+await page.evaluate(() => { window.__marker = 'same-document'; });
+log('htmx loaded: ' + await page.evaluate(() => typeof htmx + ' ' + htmx.version));
+log('rows on first page: ' + (await rows(page).count()));
+await page.fill('input[name=q]', 'kamat');
+await page.waitForFunction(() => document.querySelectorAll('#contact-table tbody tr[id]').length === 1);
+log('after typing "kamat": ' + JSON.stringify(await names(page)));
+log('address bar: ' + page.url().replace(base, ''));
+log('elements with id contact-table: ' + await page.locator('#contact-table').count());
+log('document survived (no reload): ' + (await page.evaluate(() => window.__marker)));
+
+log('\n== 2. pagination + back button');
+await page.fill('input[name=q]', '');
+await page.waitForFunction(() => document.querySelector('.pager span')?.textContent.includes('23 contacts'));
+// no history snapshots: Back is a cache miss, so htmx must ask the server for the whole page
+await page.evaluate(() => { htmx.config.historyCacheSize = 0; localStorage.removeItem('htmx-history-cache'); });
+await page.click('text=Next');
+await page.waitForFunction(() => document.querySelector('.pager span')?.textContent.startsWith('Page 2'));
+log('page 2 names: ' + JSON.stringify(await names(page)));
+log('address bar: ' + page.url().replace(base, ''));
+const mark = requests.length;
+await page.goBack();
+await page.waitForFunction(() => document.querySelector('.pager span')?.textContent.startsWith('Page 1'));
+log('Back request: ' + requests.slice(mark).join(' | '));
+log('after Back: h1=' + JSON.stringify(await page.locator('h1').textContent()) + ', pager=' + JSON.stringify(await page.locator('.pager span').textContent()));
+
+log('\n== 3. inline edit with a validation error');
+const first = rows(page).first();
+await first.locator('text=Edit').click();
+await page.waitForSelector('tr.editing');
+await page.fill('tr.editing input[name=name]', '');
+await page.fill('tr.editing input[name=email]', 'nope');
+await page.click('tr.editing >> text=Save');
+await page.waitForSelector('tr.editing small.error');
+log('row still editing, errors: ' + JSON.stringify(await page.locator('tr.editing small.error').allTextContents()));
+await page.fill('tr.editing input[name=name]', 'Aarav K.');
+await page.fill('tr.editing input[name=email]', 'aarav.k@example.com');
+await page.click('tr.editing >> text=Save');
+await page.waitForSelector('tr:not(.editing) td:text-is("Aarav K.")');
+log('saved, row is read-only again: ' + JSON.stringify(await names(page)));
+
+log('\n== 4. delete with confirm');
+const beforeDelete = requests.length;
+page.once('dialog', d => { log('confirm dialog: ' + JSON.stringify(d.message())); d.accept(); });
+await rows(page).first().locator('text=Delete').click();
+await page.waitForFunction(() => document.querySelector('.pager span')?.textContent.includes('22 contacts'));
+log('pager after delete: ' + JSON.stringify(await page.locator('.pager span').textContent()));
+await page.waitForTimeout(500);
+log('requests caused by one delete: ' + JSON.stringify(requests.slice(beforeDelete)));
+
+log('\n== 5. create form: 422 re-render, then success');
+await page.goto(base + '/contacts/new');
+await page.fill('input[name=name]', '');
+await page.fill('input[name=email]', 'x');
+await page.fill('input[name=role]', 'Tester');
+await page.click('button[type=submit]');
+await page.waitForSelector('small.error');
+log('errors shown in place: ' + JSON.stringify(await page.locator('small.error').allTextContents()));
+log('still on: ' + page.url().replace(base, '') + ', typed email kept: ' + await page.inputValue('input[name=email]'));
+await page.fill('input[name=name]', 'Zoya Khan');
+await page.fill('input[name=email]', 'zoya@example.com');
+await page.click('button[type=submit]');
+await page.waitForURL(base + '/contacts');
+log('HX-Redirect landed on: ' + page.url().replace(base, ''));
+
+log('\n== 6. the two status-code traps, observed');
+// (a) a 204 answer to DELETE: htmx 2 does not swap it, the row stays
+await page.route('**/contacts/*', async route => {
+ if (route.request().method() === 'DELETE') return route.fulfill({ status: 204 });
+ return route.continue();
+});
+const before = await rows(page).count();
+page.once('dialog', d => d.accept());
+await rows(page).first().locator('text=Delete').click();
+await page.waitForTimeout(800);
+log(`DELETE answered 204: rows before=${before}, after=${await rows(page).count()} (row not removed)`);
+await page.unroute('**/contacts/*');
+
+// (b) the same 422 without the htmx-config meta tag
+await page.route('**/contacts/*/edit', route => route.continue());
+await page.route('**/contacts', async route => {
+ if (route.request().method() !== 'GET' || route.request().headers()['hx-request']) return route.continue();
+ const resp = await route.fetch();
+ const html = (await resp.text()).replace(//, '');
+ return route.fulfill({ response: resp, body: html });
+});
+await page.goto(base + '/contacts');
+log('htmx-config meta present: ' + (await page.locator('meta[name=htmx-config]').count()));
+await rows(page).first().locator('text=Edit').click();
+await page.waitForSelector('tr.editing');
+await page.fill('tr.editing input[name=name]', '');
+await page.click('tr.editing >> text=Save');
+await page.waitForTimeout(800);
+log('Save answered 422 without the meta tag: errors visible = ' + (await page.locator('tr.editing small.error').count()));
+
+log('\n== requests the browser made (excluding static files)');
+for (const r of requests) log(r);
+await browser.close();
diff --git a/thymeleaf-htmx/browser-check/package.json b/thymeleaf-htmx/browser-check/package.json
new file mode 100644
index 0000000..e57bb40
--- /dev/null
+++ b/thymeleaf-htmx/browser-check/package.json
@@ -0,0 +1,2 @@
+{ "name": "thymeleaf-htmx-browser-check", "private": true, "type": "module",
+ "dependencies": { "playwright": "1.64.1" } }
diff --git a/thymeleaf-htmx/output/00-mvn-test.txt b/thymeleaf-htmx/output/00-mvn-test.txt
new file mode 100644
index 0000000..cc366e6
--- /dev/null
+++ b/thymeleaf-htmx/output/00-mvn-test.txt
@@ -0,0 +1 @@
+Tests run: 10, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 11.50 s -- in com.ankurm.thymeleafhtmx.ContactUiTest
diff --git a/thymeleaf-htmx/output/01-full-page-vs-fragment.txt b/thymeleaf-htmx/output/01-full-page-vs-fragment.txt
new file mode 100644
index 0000000..8e9b191
--- /dev/null
+++ b/thymeleaf-htmx/output/01-full-page-vs-fragment.txt
@@ -0,0 +1,190 @@
+# request (curl notation, sent through MockMvc): curl /contacts
+HTTP 200
+Vary: HX-Request
+Vary: HX-History-Restore-Request
+---- body (3831 chars) ----
+
+
+
+
+
+ Contacts
+
+
+
+
+
+
+
+
+ Contacts
+
+ New contact
+
+
+
+
+
+
+
+
+
+# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' /contacts
+HTTP 200
+Vary: HX-Request
+Vary: HX-History-Restore-Request
+---- body (2598 chars) ----
+
+
diff --git a/thymeleaf-htmx/output/02-history-restore-gets-full-page.txt b/thymeleaf-htmx/output/02-history-restore-gets-full-page.txt
new file mode 100644
index 0000000..e414816
--- /dev/null
+++ b/thymeleaf-htmx/output/02-history-restore-gets-full-page.txt
@@ -0,0 +1,111 @@
+# htmx sends BOTH headers when the history cache misses; it swaps the reply into
+# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' -H 'HX-History-Restore-Request: true' '/contacts?page=1'
+HTTP 200
+Vary: HX-Request
+Vary: HX-History-Restore-Request
+---- body (3937 chars) ----
+
+
+
+
+
+ Contacts
+
+
+
+
+
+
+
+
+ Contacts
+
+ New contact
+
+
+
+
+
+
+
+
+
diff --git a/thymeleaf-htmx/output/03-htmx-webjar.txt b/thymeleaf-htmx/output/03-htmx-webjar.txt
new file mode 100644
index 0000000..f3ca9a5
--- /dev/null
+++ b/thymeleaf-htmx/output/03-htmx-webjar.txt
@@ -0,0 +1,3 @@
+# script tag as rendered (the template asks for /webjars/htmx.org/dist/htmx.min.js, no version)
+#
+# GET /webjars/htmx.org/2.0.11/dist/htmx.min.js -> 200, 52182 bytes, Content-Type text/javascript
diff --git a/thymeleaf-htmx/output/04-pagination-and-search.txt b/thymeleaf-htmx/output/04-pagination-and-search.txt
new file mode 100644
index 0000000..cd08e2e
--- /dev/null
+++ b/thymeleaf-htmx/output/04-pagination-and-search.txt
@@ -0,0 +1,138 @@
+# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' '/contacts?page=4'
+HTTP 200
+---- body (1763 chars) ----
+
+
+# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' '/contacts?page=99'
+HTTP 200
+---- body (1763 chars) ----
+
+
+# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' '/contacts?q=KAMAT'
+HTTP 200
+---- body (828 chars) ----
+
+
diff --git a/thymeleaf-htmx/output/05-create-validation-errors.txt b/thymeleaf-htmx/output/05-create-validation-errors.txt
new file mode 100644
index 0000000..7d74404
--- /dev/null
+++ b/thymeleaf-htmx/output/05-create-validation-errors.txt
@@ -0,0 +1,64 @@
+# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' -d 'name=&email=not-an-email&role=Developer' /contacts
+HTTP 422
+---- body (636 chars) ----
+
+
+# request (curl notation, sent through MockMvc): curl -d 'name=&email=x@y.z&role=Tester' /contacts (no JavaScript, no HX header)
+HTTP 422
+---- body (1461 chars) ----
+
+
+
+
+
+ New contact
+
+
+
+
+
+
+
+
+ New contact
+
+
+
+
+
+
+
diff --git a/thymeleaf-htmx/output/06-create-success.txt b/thymeleaf-htmx/output/06-create-success.txt
new file mode 100644
index 0000000..eceb77f
--- /dev/null
+++ b/thymeleaf-htmx/output/06-create-success.txt
@@ -0,0 +1,16 @@
+# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' -d 'name=Zoya Khan&email=zoya@example.com&role=Developer' /contacts
+HTTP 200
+Vary: HX-Request
+Vary: HX-History-Restore-Request
+HX-Redirect: /contacts
+---- body (0 chars) ----
+
+
+# request (curl notation, sent through MockMvc): curl -d 'name=Zubin Irani&email=zubin@example.com&role=Tester' /contacts
+HTTP 302
+Vary: HX-Request
+Vary: HX-History-Restore-Request
+Location: /contacts
+---- body (0 chars) ----
+
+
diff --git a/thymeleaf-htmx/output/07-inline-edit.txt b/thymeleaf-htmx/output/07-inline-edit.txt
new file mode 100644
index 0000000..13243d3
--- /dev/null
+++ b/thymeleaf-htmx/output/07-inline-edit.txt
@@ -0,0 +1,63 @@
+# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' /contacts/1/edit (Edit button)
+HTTP 200
+---- body (523 chars) ----
+
+ |
+
+
+ |
+
+
+
+ |
+
+
+
+ |
+
+
+
+ |
+
+
+# request (curl notation, sent through MockMvc): curl -X PUT -H 'HX-Request: true' -d 'name=&email=broken&role=Architect' /contacts/1 (Save, invalid)
+HTTP 422
+---- body (625 chars) ----
+
+ |
+
+ Name is required
+ |
+
+
+ Email must look like name@host
+ |
+
+
+
+ |
+
+
+
+ |
+
+
+# request (curl notation, sent through MockMvc): curl -X PUT -H 'HX-Request: true' -d 'name=Aarav K.&email=aarav@example.com&role=Architect' /contacts/1 (Save, valid)
+HTTP 200
+---- body (382 chars) ----
+
+ | Aarav K. |
+ aarav@example.com |
+ Architect |
+
+
+
+ |
+
+
diff --git a/thymeleaf-htmx/output/08-delete.txt b/thymeleaf-htmx/output/08-delete.txt
new file mode 100644
index 0000000..193ea62
--- /dev/null
+++ b/thymeleaf-htmx/output/08-delete.txt
@@ -0,0 +1,15 @@
+# request (curl notation, sent through MockMvc): curl -X DELETE -H 'HX-Request: true' /contacts/1
+HTTP 200
+Vary: HX-Request
+Vary: HX-History-Restore-Request
+HX-Trigger: contactsChanged
+---- body (0 chars) ----
+
+
+# request (curl notation, sent through MockMvc): curl -X DELETE -H 'HX-Request: true' /contacts/999999
+HTTP 404
+Vary: HX-Request
+Vary: HX-History-Restore-Request
+---- body (0 chars) ----
+
+
diff --git a/thymeleaf-htmx/output/09-delete-last-page-clamps.txt b/thymeleaf-htmx/output/09-delete-last-page-clamps.txt
new file mode 100644
index 0000000..82ff849
--- /dev/null
+++ b/thymeleaf-htmx/output/09-delete-last-page-clamps.txt
@@ -0,0 +1,79 @@
+# deleted the 3 rows on page 5, then the table (which re-fetches page 5 after HX-Trigger) asks for page index 4
+# request (curl notation, sent through MockMvc): curl -H 'HX-Request: true' '/contacts?page=4'
+HTTP 200
+---- body (2602 chars) ----
+
+
diff --git a/thymeleaf-htmx/output/10-live-curl.txt b/thymeleaf-htmx/output/10-live-curl.txt
new file mode 100644
index 0000000..ed1fc9a
--- /dev/null
+++ b/thymeleaf-htmx/output/10-live-curl.txt
@@ -0,0 +1,35 @@
+$ curl -si http://localhost:18082/contacts | tr -d '\r' | grep -v -E '^(Date|Transfer-Encoding)' | head -10
+HTTP/1.1 200
+Vary: HX-Request
+Vary: HX-History-Restore-Request
+Content-Type: text/html;charset=UTF-8
+Content-Language: en-US
+
+
+
+
+
+
+$ curl -si -H 'HX-Request: true' http://localhost:18082/contacts | tr -d '\r' | grep -v -E '^(Date|Transfer-Encoding)' | head -8
+HTTP/1.1 200
+Vary: HX-Request
+Vary: HX-History-Restore-Request
+Content-Type: text/html;charset=UTF-8
+Content-Language: en-US
+
+