HTMX with Grails 8
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
Grails Version: 8
1 Getting Started
In this guide you will build a small task tracker on Apache Grails 8 with 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.
Files touched: none.
1.1 What You Will Build
By the end of the guide your application will have:
-
A
Taskdomain withtitle(required) anddone(boolean). -
A
TaskControllerwith eight actions:index(full page),show(single row),search(live filter),create(new task),editForm+update(inline edit),toggle(flip the done flag), anddelete. Every non-indexaction returns a tiny GSP fragment, not a full page. -
GSP partials at
_task.gsp,_taskRows.gsp,_taskEdit.gsp,_taskForm.gsp, and_taskCreated.gsp. The create flow returns one response that refreshes the form and prepends the new row withhx-swap-oob. -
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-confirmand an outerHTML swap that animates the row out. -
A CSRF section that distinguishes Grails' built-in
useToken/withFormduplicate-submit token support from Spring Security’s_csrfrequest attribute and shows how to forward the Spring Security header for HTMX requests when that filter is present.
Files touched: none.
Files touched: none.
1.2 What You Will Need
To complete this guide you will need:
-
JDK 21
-
About 30 minutes
Files touched: none.
1.3 How to Complete the Guide
You can either type the code in this guide as you read or skip ahead and clone the finished sample:
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 web starter. complete/ adds the Task domain, the controller, the URL mappings, the GSP partials, the htmx.org webjar dependency, and the asset-pipeline manifest entry that serves htmx.min.js.
Files touched: none.
2 Creating the Application
Open start.grails.org, pick the web profile, name the application htmxtasks, set the package to example, choose JDK 21, and download the generated zip. Unzip it and cd into the htmxtasks directory.
The default web starter already gives you the pieces this guide needs from Grails 8 core:
-
GSP support via
org.apache.grails:grails-gsp, managed by the Grails BOM. -
Asset Pipeline support via the
cloud.wondrify:asset-pipeline-*modules, with version5.0.32coming from the Grails BOM. -
Bootstrap and Bootstrap Icons webjars, which the starter’s asset manifests already require.
HTMX is not part of the Grails 8 BOM or the web profile starter, so add the htmx webjar yourself:
dependencies {
implementation 'org.webjars.npm:htmx.org:2.0.4'
}
Then require the htmx asset in the JavaScript manifest:
// This is a manifest file that'll be compiled into application.js.
//
// Any JavaScript file within this directory can be referenced here using a relative path.
//
// You're free to add application-wide JavaScript to this file, but it's generally better
// to create separate JavaScript files as needed.
//
//= require webjars/jquery/%/dist/jquery.js
//= require webjars/bootstrap/%/dist/js/bootstrap.bundle.js
//= require webjars/htmx.org/%/dist/htmx.min.js
//= require_self
if (typeof jQuery !== 'undefined') {
(function($) {
$('#spinner').ajaxStart(function() {
$(this).fadeIn();
}).ajaxStop(function() {
$(this).fadeOut();
});
})(jQuery);
}
Using org.webjars.npm:htmx.org:2.0.4 keeps the delivery story consistent with the Grails 8 starter: Asset Pipeline serves the file from the webjar, just as it already does for Bootstrap and jQuery.
Files touched:
-
build.gradle -
grails-app/assets/javascripts/application.js
3 The Task Domain Class
A small Task domain class drives the UI:
package example
import grails.persistence.Entity
@Entity
class Task {
String title
Boolean done = false
Date dateCreated
static constraints = {
title blank: false, maxSize: 255
}
static mapping = {
sort 'dateCreated'
order 'desc'
}
}
title blank: false, maxSize: 255 is the only constraint that matters for this guide; we will exercise it in both the create form and the inline edit form. dateCreated is a GORM-managed timestamp. static mapping { sort 'dateCreated'; order 'desc' } makes new tasks appear at the top of the list without any controller-side sorting code.
Files touched:
-
grails-app/domain/example/Task.groovy
4 Adding HTMX to the Layout
The Grails 8 web starter already serves frontend libraries through Asset Pipeline and webjars. Follow that same pattern for HTMX instead of adding a CDN <script> tag:
// This is a manifest file that'll be compiled into application.js.
//
// Any JavaScript file within this directory can be referenced here using a relative path.
//
// You're free to add application-wide JavaScript to this file, but it's generally better
// to create separate JavaScript files as needed.
//
//= require webjars/jquery/%/dist/jquery.js
//= require webjars/bootstrap/%/dist/js/bootstrap.bundle.js
//= require webjars/htmx.org/%/dist/htmx.min.js
//= require_self
if (typeof jQuery !== 'undefined') {
(function($) {
$('#spinner').ajaxStart(function() {
$(this).fadeIn();
}).ajaxStop(function() {
$(this).fadeOut();
});
})(jQuery);
}
The layout then stays simple. It just loads the compiled application.js bundle and, if Spring Security has exposed a _csrf request attribute, it forwards that token for HTMX requests:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title><g:layoutTitle default="Grails + HTMX"/></title>
<asset:link rel="icon" href="favicon.ico" type="image/x-ico"/>
<asset:stylesheet src="application.css"/>
<g:set var="csrfToken" value="${request.getAttribute('_csrf')}"/>
<g:if test="${csrfToken}">
<meta name="csrf-header" content="${csrfToken.headerName}"/>
<meta name="csrf-token" content="${csrfToken.token}"/>
<script>
document.addEventListener('htmx:configRequest', function(event) {
const header = document.querySelector('meta[name="csrf-header"]')?.content;
const token = document.querySelector('meta[name="csrf-token"]')?.content;
if (header && token) {
event.detail.headers[header] = token;
}
});
</script>
</g:if>
<g:layoutHead/>
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body border-bottom shadow-sm">
<div class="container-lg">
<a class="navbar-brand" href="${request.contextPath}/">
Grails + HTMX
</a>
</div>
</nav>
<g:layoutBody/>
<asset:javascript src="application.js"/>
</body>
</html>
There is no official htmx dependency in the Grails 8 BOM or the web profile itself, but org.webjars.npm:htmx.org:2.0.4 works cleanly with the same //= require webjars/… convention the starter already uses for Bootstrap and jQuery.
Files touched:
-
build.gradle -
grails-app/assets/javascripts/application.js -
grails-app/views/layouts/main.gsp
5 URL Mappings
Each HTMX endpoint is its own URL with an explicit method:
package example
class UrlMappings {
static mappings = {
"/tasks"(controller: 'task', action: 'index', method: 'GET')
"/tasks"(controller: 'task', action: 'create', method: 'POST')
"/tasks/search"(controller: 'task', action: 'search', method: 'GET')
"/tasks/$id"(controller: 'task', action: 'show', method: 'GET')
"/tasks/$id/edit"(controller: 'task', action: 'editForm', method: 'GET')
"/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')
}
}
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.
This is also close to the Grails 8 web profile skeleton, which still shows explicit "/$controller/$action?/$id?(.$format)?" mappings in UrlMappings.groovy.
Files touched:
-
grails-app/controllers/example/UrlMappings.groovy
6 The TaskController
TaskController keeps the full-page render and the fragment renders separate. index uses render view: for the initial page; every HTMX endpoint returns a fragment with render template::
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'
]
def index() {
List<Task> tasks = Task.list(params)
render view: 'index', model: [tasks: tasks, task: new Task(), q: '']
}
def show(Long id) {
Task task = Task.get(id)
if (!task) {
response.status = HttpStatus.NOT_FOUND.value()
return
}
render template: 'task', model: [task: task]
}
def search() {
String q = (params.q ?: '').trim()
List<Task> rows = q ? Task.findAllByTitleIlike("%${q}%") : Task.list()
render template: 'taskRows', model: [tasks: rows]
}
@Transactional
def create() {
Task task = new Task(title: params.title)
if (!task.validate()) {
response.status = HttpStatus.UNPROCESSABLE_ENTITY.value()
render template: 'taskForm', model: [task: task]
return
}
task.save(flush: true)
render template: 'taskCreated', model: [task: task, formTask: new Task()]
}
def editForm(Long id) {
Task task = Task.get(id)
if (!task) {
response.status = HttpStatus.NOT_FOUND.value()
return
}
render template: 'taskEdit', model: [task: task]
}
@Transactional
def update(Long id) {
Task task = Task.get(id)
if (!task) {
response.status = HttpStatus.NOT_FOUND.value()
return
}
task.title = params.title
if (!task.save(flush: true)) {
response.status = HttpStatus.UNPROCESSABLE_ENTITY.value()
render template: 'taskEdit', model: [task: task]
return
}
render template: 'task', model: [task: task]
}
@Transactional
def toggle(Long id) {
Task task = Task.get(id)
if (!task) {
response.status = HttpStatus.NOT_FOUND.value()
return
}
task.done = !task.done
task.save(flush: true)
render template: 'task', model: [task: task]
}
@Transactional
def delete(Long id) {
Task task = Task.get(id)
if (!task) {
response.status = HttpStatus.NOT_FOUND.value()
return
}
task.delete(flush: true)
render ''
}
}
A few patterns to point at:
-
static allowedMethods = […]rejects mismatched HTTP verbs at the framework level. SendingGET /taskstocreatereturns 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. -
show(Long id)returns the read-only row fragment. That gives the Cancel button in the edit form a clean endpoint to reload. -
On a validation failure the controller returns
422 Unprocessable Entitywith the same fragment the browser was already showing. HTMX swaps that fragment in place, so the user keeps context and sees the errors inline. -
create()renders_taskCreated.gsp, which combines a fresh form plus an out-of-band prepend of the new row. This avoids hand-written client-side reset logic. -
delete()returns an empty body. Combined withhx-target="closest li"andhx-swap="outerHTML", that removes the row from the DOM.
Files touched:
-
grails-app/controllers/example/TaskController.groovy -
grails-app/views/task/_taskCreated.gsp
7 The Initial Page
The full page is rendered once on GET /tasks:
<!doctype html>
<html>
<head>
<title>HTMX Task Tracker</title>
<meta name="layout" content="main"/>
</head>
<body>
<main class="container-lg py-4">
<h1 class="h3 mb-3">Tasks</h1>
<g:render template="taskForm" model="[task: task]"/>
<%-- Live search --%>
<input type="text"
name="q"
value="${q}"
placeholder="Search tasks..."
class="form-control mb-3"
hx-get="${createLink(controller: 'task', action: 'search')}"
hx-trigger="keyup changed delay:300ms"
hx-target="#taskList"
hx-swap="innerHTML"/>
<ul id="taskList" class="list-group">
<g:render template="taskRows" model="[tasks: tasks]"/>
</ul>
</main>
</body>
</html>
Three HTMX patterns are in play:
-
The add-task form comes from
_taskForm.gsp, not raw inline markup inindex.gsp. Wrapping it in a partial lets the controller return the same fragment on validation failure and a fresh fragment after success. -
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.
Files touched:
-
grails-app/views/task/index.gsp -
grails-app/views/task/_taskForm.gsp
8 The Row Partial
Every row is a _task.gsp partial. The same partial is used by:
-
The initial server render (via
_taskRows.gspwhich iterates_task.gsp). -
The "create" response after a successful POST.
-
The "toggle done" response.
-
The "save edit" response.
<li id="task-${task.id}" class="list-group-item d-flex align-items-center gap-3${task.done ? ' text-decoration-line-through text-body-secondary' : ''}">
<button type="button"
class="btn btn-sm ${task.done ? 'btn-success' : 'btn-outline-secondary'}"
hx-post="${createLink(controller: 'task', action: 'toggle', id: task.id)}"
hx-target="closest li"
hx-swap="outerHTML"
aria-pressed="${task.done}"
aria-label="Toggle done">
${task.done ? '\u2713' : '\u00b7'}
</button>
<button type="button"
class="btn btn-link p-0 text-start text-decoration-none flex-grow-1 ${task.done ? 'text-body-secondary' : 'text-body'}"
hx-get="${createLink(controller: 'task', action: 'editForm', id: task.id)}"
hx-target="closest li"
hx-swap="outerHTML"
title="Click to edit">
<g:fieldValue bean="${task}" field="title"/>
</button>
<button type="button"
class="btn btn-sm btn-outline-danger"
hx-delete="${createLink(controller: 'task', action: 'delete', id: task.id)}"
hx-target="closest li"
hx-swap="outerHTML swap:200ms"
hx-confirm="Delete this task?"
aria-label="Delete">
\u2715
</button>
</li>
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. Theclosestmodifier walks up the DOM until it finds an<li>. -
Clicking the title button fires a
hx-getfor the edit form. Same swap target. The result is the inline edit form, replacing the read-only row. -
The delete button uses
hx-confirmfor an in-browser confirm dialog,hx-swap="outerHTML swap:200ms"for a 200 ms delayed swap, and a DELETE that returns an empty body.
The list partial is even simpler:
<g:render template="task" collection="${tasks}" var="task"/>
<g:if test="${!tasks}">
<li class="list-group-item text-body-secondary">No tasks yet. Add one above.</li>
</g:if>
It uses <g:render template="task" collection="${tasks}" var="task"/>, which is the idiomatic Grails way to render one template per element, and falls back to a placeholder when the list is empty.
Files touched:
-
grails-app/views/task/_task.gsp -
grails-app/views/task/_taskRows.gsp
9 Inline Edit With Validation
Clicking a task title swaps the row out for an edit form. Saving swaps it back. The state never leaves the server.
<li id="task-${task.id}" class="list-group-item">
<g:form controller="task"
action="update"
id="${task.id}"
class="d-flex gap-2"
hx-patch="${createLink(controller: 'task', action: 'update', id: task.id)}"
hx-target="closest li"
hx-swap="outerHTML">
<g:textField name="title"
value="${task.title}"
maxlength="255"
class="form-control"
autofocus="autofocus"/>
<button type="submit" class="btn btn-primary btn-sm">Save</button>
<button type="button"
class="btn btn-outline-secondary btn-sm"
hx-get="${createLink(controller: 'task', action: 'show', id: task.id)}"
hx-target="closest li"
hx-swap="outerHTML">Cancel</button>
</g:form>
<g:if test="${task.errors?.hasErrors()}">
<ul class="text-danger small mt-2 mb-0">
<g:eachError bean="${task}" field="title"><li>${message(error: it).encodeAsHTML()}</li></g:eachError>
</ul>
</g:if>
</li>
Three patterns to highlight:
-
The form’s
hx-patchposts to/tasks/{id}with the PATCH method. The response is either the read-only_task.gspon success or the same edit form with validation errors on failure. -
Wrapping the markup in
<g:form>keeps the snippet idiomatic Grails. If Spring Security CSRF is active,FormTagLibwill automatically add its hidden field to this form. -
<g:eachError bean="${task}" field="title">renders only the field errors we care about. Because Grails populatestask.errorswhensave()fails, the same partial template handles both failure and re-render. -
The Cancel button does a
hx-getfor/tasks/{id}so cancelling reloads the canonical read-only row fragment instead of trying to rebuild it in JavaScript.
Files touched:
-
grails-app/views/task/_taskEdit.gsp -
grails-app/controllers/example/TaskController.groovy
10 Live Search
Live search is one input element with three HTMX attributes:
<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:
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. The _taskRows.gsp partial handles both the populated and empty states, so the controller action stays tiny.
Files touched:
-
grails-app/controllers/example/TaskController.groovy -
grails-app/views/task/_taskRows.gsp
11 CSRF With HTMX
There are two different token stories to keep straight in Grails 8.
First, Grails core has the long-standing useToken / withForm mechanism. It is implemented by FormTagLib, Controller.withForm, and SynchronizerTokensHolder, and it is primarily a duplicate-submit token flow:
<g:form useToken="true" ...>
...
</g:form>
withForm {
// valid token
}.invalidToken {
// invalid or replayed token
}
That mechanism works well for normal Grails forms. It is not, by itself, a built-in HTMX-wide CSRF filter for button-driven hx-post, hx-patch, or hx-delete requests.
Second, if Spring Security’s CSRF filter is on the classpath and active, Grails exposes the _csrf request attribute and FormTagLib automatically writes the hidden field into <g:form>. For HTMX requests triggered from links and buttons, forward that header globally:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title><g:layoutTitle default="Grails + HTMX"/></title>
<asset:link rel="icon" href="favicon.ico" type="image/x-ico"/>
<asset:stylesheet src="application.css"/>
<g:set var="csrfToken" value="${request.getAttribute('_csrf')}"/>
<g:if test="${csrfToken}">
<meta name="csrf-header" content="${csrfToken.headerName}"/>
<meta name="csrf-token" content="${csrfToken.token}"/>
<script>
document.addEventListener('htmx:configRequest', function(event) {
const header = document.querySelector('meta[name="csrf-header"]')?.content;
const token = document.querySelector('meta[name="csrf-token"]')?.content;
if (header && token) {
event.detail.headers[header] = token;
}
});
</script>
</g:if>
<g:layoutHead/>
</head>
<body>
<nav class="navbar navbar-expand-lg bg-body border-bottom shadow-sm">
<div class="container-lg">
<a class="navbar-brand" href="${request.contextPath}/">
Grails + HTMX
</a>
</div>
</nav>
<g:layoutBody/>
<asset:javascript src="application.js"/>
</body>
</html>
This guide’s layout only adds those <meta> tags and the htmx:configRequest listener when _csrf exists. That keeps the sample app truthful:
-
without Spring Security, the app is just a plain Grails 8 sample and the listener is inert;
-
with Spring Security CSRF enabled, HTMX requests automatically carry the expected header name and token value.
|
If you stay entirely inside |
Files touched:
-
grails-app/views/layouts/main.gsp -
grails-app/views/task/_taskForm.gsp -
grails-app/views/task/_taskEdit.gsp
12 HTMX vs an SPA
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 Grails 8 + Vite SPA guide covers the SPA path for the cases that need it.
Files touched: none.
13 Do you need help with Grails?
Help with Apache Grails
Apache Grails is supported by an active community of contributors and the Apache Software Foundation. If you need help working through a guide, want to discuss the framework, or have run into something that looks like a bug, the channels below are the right place to start.
-
Slack - real-time conversation with the Apache Grails community.
-
Developer mailing list - design discussions and contributor coordination.
-
Users mailing list - end-user questions and answers.
-
Issue tracker on GitHub - file a bug or feature request against the framework.
For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.
Files touched: none.