Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions conf/guides.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2429,6 +2429,57 @@ guides:
helpWithGrails:
title: Help with Grails

- name: 'grails-htmx'
title: 'HTMX with Grails 8'
subtitle: 'Build a small task tracker with server-rendered Grails 8 GSP and HTMX-driven inline editing, live search, optimistic delete, and toggle - no SPA, no JSON.'
authors:
- 'James Fredley'
category: 'Web Layer'
publicationDate: '2026-05-03'
versions:
'8':
sourcePath: guides/grails-htmx/v8
publicationDate: '2026-05-03'
tags:
- 'htmx'
- 'gsp'
- 'partials'
- 'no-spa'
- 'grails8'
sampleRef:
repo: 'grails-guides/grails-htmx'
branch: 'grails8'
toc:
gettingStarted:
title: Getting Started
whatYouWillBuild: What You Will Build
requirements: What You Will Need
howto: How to Complete the Guide
createApp:
title: Creating the Application
taskDomain:
title: The Task Domain Class
layoutWithHtmx:
title: Adding HTMX to the Layout
urlMappings:
title: URL Mappings
controller:
title: The TaskController
indexPage:
title: The Initial Page
rowPartial:
title: The Row Partial
inlineEdit:
title: Inline Edit With Validation
liveSearch:
title: Live Search
csrf:
title: CSRF With HTMX
spaComparison:
title: HTMX vs an SPA
helpWithGrails:
title: Do you need help with Grails?

- name: 'grails-javamelody'
title: 'JavaMelody monitoring with Grails 3'
subtitle: 'Learn how to setup and monitor your application using JavaMelody'
Expand Down
14 changes: 14 additions & 0 deletions guides/grails-htmx/v8/guide/controller.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
`TaskController` has seven actions, but only `index` returns a full page. Every other action returns a single GSP partial via `render template: '...'`:

[source,groovy]
.grails-app/controllers/example/TaskController.groovy
----
include::../snippets/grails-app/controllers/example/TaskController.groovy[]
----

A few patterns to point at:

* `static allowedMethods = [...]` rejects mismatched HTTP verbs at the framework level. Sending `GET /tasks` to `create` returns 405, not silently calling the action.
* The `@Transactional(readOnly = true)` class annotation flips read-only mode on for the whole controller; individual mutating actions override with their own `@Transactional`.
* On a validation failure (`!t.save()`), the controller returns a 422 with the error fragment. HTMX swaps that fragment into the page, the user sees the error inline, and the form remains in the DOM ready for another attempt.
* `delete` returns an empty body. Combined with `hx-swap="outerHTML"` on the delete button, this removes the row from the DOM.
11 changes: 11 additions & 0 deletions guides/grails-htmx/v8/guide/createApp.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Generate a fresh Apache Grails 8 web application from link:https://prev-snapshot.grails.org[prev-snapshot.grails.org]:

[source,bash]
----
curl -L -o htmx-app.zip \
"https://prev-snapshot.grails.org/create/web/example.htmxtasks?lang=GROOVY&build=GRADLE&test=SPOCK&javaVersion=JDK_21"
unzip htmx-app.zip
cd htmxtasks
----

The default `web` starter ships GSP, the asset pipeline, and Bootstrap 5 webjars - all of which we will reuse.
23 changes: 23 additions & 0 deletions guides/grails-htmx/v8/guide/csrf.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
Grails' `<g:form>` tag automatically inserts a `SYNCHRONIZER_TOKEN` parameter when the controller declares `static withForm = ...`. HTMX requests do not go through `<g:form>`, so the token has to be wired in differently.

The HTMX configuration block in `main.gsp` reads the token from a `<meta>` tag and forwards it as a custom header on every request:

[source,html]
----
<meta name="csrf-token" content="${useToken().token}"/>
<script>
document.body.addEventListener('htmx:configRequest', function(evt) {
var meta = document.querySelector('meta[name="csrf-token"]');
if (meta) {
evt.detail.headers['X-Grails-Csrf-Token'] = meta.content;
}
});
</script>
----

A matching `before` interceptor checks the header on every state-changing request and rejects mismatches with a 403. For a sample app the token check is overkill; for a real back-office the few lines of plumbing buy you the same protection `<g:form>` provides for traditional forms.

[NOTE]
====
The `Origin` and `Referer` headers also defeat CSRF for fetch-based clients. Modern browsers send them automatically and a same-origin check is one line of interceptor code. Belt-and-braces shops do both; pick the discipline that matches your threat model.
====
5 changes: 5 additions & 0 deletions guides/grails-htmx/v8/guide/gettingStarted.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
In this guide you will build a small task tracker on Apache Grails 8 with link:https://htmx.org[HTMX] driving the UI - server-rendered initial page, then HTMX-driven inline editing, live search, optimistic delete with confirm, and toggle, all without committing to a full SPA.

The pattern is a natural fit for Grails: GSP partials become HTMX response fragments. Your controllers return small chunks of HTML instead of JSON; HTMX swaps them into the right slot. Forms post to controllers via `hx-post` and validation errors come back as the same partial with the errors rendered inline.

This guide targets Apache Grails 8 / HTMX 2.x.
1 change: 1 addition & 0 deletions guides/grails-htmx/v8/guide/helpWithGrails.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
include::{commondir}/common-helpWithGrails.adoc[]
11 changes: 11 additions & 0 deletions guides/grails-htmx/v8/guide/howto.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
You can either type the code in this guide as you read or skip ahead and clone the finished sample:

[source,bash]
----
git clone -b grails8 https://github.com/grails-guides/grails-htmx.git
cd grails-htmx/complete
./gradlew bootRun
# open http://localhost:8080/tasks
----

`initial/` is a vanilla Grails 8 starter. `complete/` adds the Task domain, the controller, the GSP partials, and the HTMX script tag.
15 changes: 15 additions & 0 deletions guides/grails-htmx/v8/guide/indexPage.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
The full page is rendered once on `GET /tasks`:

[source,html]
.grails-app/views/task/index.gsp
----
include::../snippets/grails-app/views/task/index.gsp[]
----

Three HTMX patterns are in play:

* The *add-task form* posts to `/tasks` (`hx-post`) and prepends the response to `#taskList` (`hx-target="#taskList"`, `hx-swap="afterbegin"`). The `hx-on::after-request="this.reset()"` clears the input after a successful submission.
* The *live-search input* fires a `GET /tasks/search?q=...` whenever the user pauses typing for 300 ms (`hx-trigger="keyup changed delay:300ms"`). The response replaces the contents of `#taskList`.
* The *initial render* uses `<g:render template="taskRows" .../>` to populate the list server-side. HTMX takes over from there.

This is the canonical HTMX recipe: render once on the server, then let small fragments shuttle in and out of well-defined slots in the DOM.
13 changes: 13 additions & 0 deletions guides/grails-htmx/v8/guide/inlineEdit.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Clicking a task title swaps the row out for an edit form. Saving swaps it back. The state never leaves the server.

[source,html]
.grails-app/views/task/_taskEdit.gsp
----
include::../snippets/grails-app/views/task/_taskEdit.gsp[]
----

Three patterns to highlight:

* The form's `hx-patch` posts to `/tasks/{id}` with the PATCH method. The response is either the read-only `_task.gsp` (success) or the same edit form with the validation errors rendered (failure).
* `<g:eachError bean="${task}">` renders the field errors inline. Because Grails populates `task.errors` when `save()` fails, the same partial template handles both success and failure. The HTTP status (200 vs 422) is what tells HTMX which one - both swap into the same slot.
* The Cancel button does a `hx-get` for `/tasks/{id}` (which returns the read-only row partial) so cancelling does not require any new wiring.
11 changes: 11 additions & 0 deletions guides/grails-htmx/v8/guide/layoutWithHtmx.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
HTMX is a single ~14 KB script. The starter's layout already loads stylesheets and the Grails javascript bundle; add the HTMX script tag in `<head>`:

[source,html]
.grails-app/views/layouts/main.gsp
----
include::../snippets/grails-app/views/layouts/main.gsp[]
----

The `integrity=` attribute uses a Subresource Integrity hash that the browser verifies before executing - protecting you against a CDN compromise. Keep it in sync with the version in the `src` URL.

For production, vendor the file under `grails-app/assets/javascripts/htmx.min.js` and load via `<asset:javascript src="htmx.min.js"/>` so deployments do not depend on the CDN.
31 changes: 31 additions & 0 deletions guides/grails-htmx/v8/guide/liveSearch.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
Live search is one input element with three HTMX attributes:

[source,html]
----
<input type="text"
name="q"
placeholder="Search tasks..."
hx-get="${createLink(controller: 'task', action: 'search')}"
hx-trigger="keyup changed delay:300ms"
hx-target="#taskList"
hx-swap="innerHTML"/>
----

The `hx-trigger="keyup changed delay:300ms"` modifier composition is the entire UX:

* `keyup` - fire on every key release.
* `changed` - skip if the input value did not actually change since the last request (so arrow keys, modifiers, and Ctrl+A do not trigger a network round-trip).
* `delay:300ms` - wait 300 ms after the last keyup before sending. New keypresses cancel the pending request and reset the timer.

The server-side action is one line of GORM:

[source,groovy]
----
def search() {
String q = (params.q ?: '') as String
List<Task> rows = q ? Task.findAllByTitleIlike("%${q}%") : Task.list()
render template: 'taskRows', model: [tasks: rows]
}
----

`findAllByTitleIlike` is GORM's case-insensitive LIKE; it forwards to a parameterised SQL query, so no SQL injection concerns. The `_taskRows.gsp` partial handles both the populated and empty states.
4 changes: 4 additions & 0 deletions guides/grails-htmx/v8/guide/requirements.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
To complete this guide you will need:

* JDK 21
* About 30 minutes
28 changes: 28 additions & 0 deletions guides/grails-htmx/v8/guide/rowPartial.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
Every row is a `_task.gsp` partial. The same partial is used by:

* The initial server render (via `_taskRows.gsp` which iterates `_task.gsp`).
* The "create" response after a successful POST.
* The "toggle done" response.
* The "save edit" response.

[source,html]
.grails-app/views/task/_task.gsp
----
include::../snippets/grails-app/views/task/_task.gsp[]
----

Three HTMX-specific things in this partial:

* The toggle-done button uses `hx-target="closest li"` `hx-swap="outerHTML"`. After the POST, the new HTML replaces *this entire row* in place. The `closest` modifier walks up the DOM until it finds an `<li>`.
* Clicking the title fires a `hx-get` for the edit form. Same swap target. The result is the inline edit form, replacing the read-only row.
* The delete button uses `hx-confirm` for an in-browser confirm dialog, `hx-swap="outerHTML swap:200ms"` for a 200 ms fade-out animation, and a DELETE that returns an empty body.

The list partial is even simpler:

[source,html]
.grails-app/views/task/_taskRows.gsp
----
include::../snippets/grails-app/views/task/_taskRows.gsp[]
----

It iterates `_task.gsp` and falls back to a placeholder when the list is empty.
16 changes: 16 additions & 0 deletions guides/grails-htmx/v8/guide/spaComparison.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
When does HTMX fit and when do you reach for an SPA?

HTMX wins when:

* The UI is fundamentally CRUD - lists, forms, partial updates, optimistic deletes. The task tracker in this guide is the canonical shape.
* SEO matters - HTMX pages are server-rendered HTML on first paint, so search engines and link previews see the real content.
* The team is more comfortable on the server than in JavaScript - HTMX shifts complexity away from the SPA's reactive state model into plain controller actions and GSP partials.
* The deploy story is simpler when there is one bootJar instead of a backend + a frontend build pipeline.

SPAs (React, Vue, Svelte) win when:

* The UI is genuinely client-stateful - a real-time editor, a Kanban board with drag-and-drop, an analytics dashboard with cross-filter interactions. HTMX can do these but you fight the framework.
* Offline-first matters - HTMX is built around server round-trips by design.
* The same backend serves multiple frontends (web + native mobile) and you want a single JSON API surface for both.

Most internal back-office apps land squarely in HTMX's sweet spot. The link:../grails-vite-spa/8/guide/index.html[Grails 8 + Vite SPA guide] covers the SPA path for the cases that need it.
9 changes: 9 additions & 0 deletions guides/grails-htmx/v8/guide/taskDomain.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
A small Task domain class drives the UI:

[source,groovy]
.grails-app/domain/example/Task.groovy
----
include::../snippets/grails-app/domain/example/Task.groovy[]
----

`title blank: false, maxSize: 255` is the only constraint that matters for this guide; we will exercise it via the validation feedback path on inline edit. `dateCreated` is a GORM-managed timestamp. `static mapping { sort 'dateCreated'; order 'desc' }` makes new tasks appear at the top of the list.
9 changes: 9 additions & 0 deletions guides/grails-htmx/v8/guide/urlMappings.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Each HTMX endpoint is its own URL with an explicit method:

[source,groovy]
.grails-app/controllers/example/UrlMappings.groovy
----
include::../snippets/grails-app/controllers/example/UrlMappings.groovy[]
----

The repetition (`/tasks` for both index and create, distinguished by method) is intentional: HTMX requests carry their semantics in the HTTP method, exactly as REST does. Using `'/tasks'(resources: 'task')` would also work, but the explicit-route form makes the surface easier to read for an author who has never seen Grails' resource-mapping shorthand.
9 changes: 9 additions & 0 deletions guides/grails-htmx/v8/guide/whatYouWillBuild.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
By the end of the guide your application will have:

* A `Task` domain with `title` (required) and `done` (boolean), with sample data seeded by `BootStrap` so the page is non-empty on first load.
* A `TaskController` with seven actions: `index` (full page), `search` (live filter), `create` (new task), `editForm` + `update` (inline edit), `toggle` (flip the done flag), `delete`. Every non-`index` action returns a tiny GSP partial, not a full page.
* GSP partials at `_task.gsp`, `_taskRows.gsp`, and `_taskEdit.gsp` that the controller renders via `<g:render template="..."/>`. One partial per logical state.
* The four core HTMX attributes (`hx-get`, `hx-post`, `hx-patch`, `hx-delete`) plus the swap targeting attributes (`hx-target`, `hx-swap`).
* Live search via `hx-trigger="keyup changed delay:300ms"` so the rows partial refreshes after the user pauses typing.
* Optimistic delete with confirm via `hx-confirm` and an outerHTML swap that animates the row out.
* CSRF token forwarding configured globally so HTMX requests carry the right header on every POST/PATCH/DELETE.
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package example

import grails.gorm.transactions.Transactional
import org.springframework.http.HttpStatus

@Transactional(readOnly = true)
class TaskController {

static allowedMethods = [
create: 'POST', update: 'PATCH', delete: 'DELETE', toggle: 'POST'
]

/** Full page (index.gsp). */
def index() {
respond Task.list(params), model: [tasks: Task.list(params), q: '']
}

/** Live-search endpoint - returns just the rows partial. */
def search() {
String q = (params.q ?: '') as String
List<Task> rows = q ? Task.findAllByTitleIlike("%${q}%") : Task.list()
render template: 'taskRows', model: [tasks: rows]
}

/** Add a new task - returns the new row prepended to the list. */
@Transactional
def create() {
Task t = new Task(title: params.title)
if (!t.save()) {
response.status = HttpStatus.UNPROCESSABLE_ENTITY.value()
render template: 'taskForm', model: [task: t]
return
}
render template: 'task', model: [task: t]
}

/** Inline-edit endpoint - returns the edit form for a single row. */
def editForm(Long id) {
Task t = Task.get(id)
if (!t) { response.status = 404; return }
render template: 'taskEdit', model: [task: t]
}

/** Apply an inline edit - returns the read-only row partial. */
@Transactional
def update(Long id) {
Task t = Task.get(id)
if (!t) { response.status = 404; return }
t.properties = params
if (!t.save()) {
response.status = HttpStatus.UNPROCESSABLE_ENTITY.value()
render template: 'taskEdit', model: [task: t]
return
}
render template: 'task', model: [task: t]
}

/** Toggle the `done` flag - returns the row partial. */
@Transactional
def toggle(Long id) {
Task t = Task.get(id)
if (!t) { response.status = 404; return }
t.done = !t.done
t.save()
render template: 'task', model: [task: t]
}

/** Delete - HTMX swaps the row out via hx-target="closest tr" hx-swap="outerHTML". */
@Transactional
def delete(Long id) {
Task t = Task.get(id)
if (!t) { response.status = 404; return }
t.delete()
render ''
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package example

class UrlMappings {

static mappings = {

'/tasks'(controller: 'task', action: 'index')
'/tasks/search'(controller: 'task', action: 'search')
'/tasks'(controller: 'task', action: 'create', method: 'POST')
'/tasks/$id/edit'(controller: 'task', action: 'editForm')
'/tasks/$id'(controller: 'task', action: 'update', method: 'PATCH')
'/tasks/$id'(controller: 'task', action: 'delete', method: 'DELETE')
'/tasks/$id/toggle'(controller: 'task', action: 'toggle', method: 'POST')

'/'(redirect: '/tasks')

'500'(view: '/error')
'404'(view: '/notFound')
}
}
Loading
Loading