Show Navigation

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 Task domain with title (required) and done (boolean).

  • A TaskController with eight actions: index (full page), show (single row), search (live filter), create (new task), editForm + update (inline edit), toggle (flip the done flag), and delete. Every non-index action 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 with hx-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-confirm and an outerHTML swap that animates the row out.

  • A CSRF section that distinguishes Grails' built-in useToken / withForm duplicate-submit token support from Spring Security’s _csrf request 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 version 5.0.32 coming 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:

build.gradle
dependencies {
    implementation 'org.webjars.npm:htmx.org:2.0.4'
}

Then require the htmx asset in the JavaScript manifest:

grails-app/assets/javascripts/application.js
// 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:

grails-app/domain/example/Task.groovy
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:

grails-app/assets/javascripts/application.js
// 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:

grails-app/views/layouts/main.gsp
<!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:

grails-app/controllers/example/UrlMappings.groovy
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::

grails-app/controllers/example/TaskController.groovy
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. 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.

  • 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 Entity with 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 with hx-target="closest li" and hx-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:

grails-app/views/task/index.gsp
<!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 in index.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.gsp which iterates _task.gsp).

  • The "create" response after a successful POST.

  • The "toggle done" response.

  • The "save edit" response.

grails-app/views/task/_task.gsp
<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. The closest modifier walks up the DOM until it finds an <li>.

  • Clicking the title button 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 delayed swap, and a DELETE that returns an empty body.

The list partial is even simpler:

grails-app/views/task/_taskRows.gsp
<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.

grails-app/views/task/_taskEdit.gsp
<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-patch posts to /tasks/{id} with the PATCH method. The response is either the read-only _task.gsp on 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, FormTagLib will automatically add its hidden field to this form.

  • <g:eachError bean="${task}" field="title"> renders only the field errors we care about. Because Grails populates task.errors when save() fails, the same partial template handles both failure and re-render.

  • The Cancel button does a hx-get for /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:

grails-app/views/layouts/main.gsp
<!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 <g:form> submissions, Grails' form helpers already do the right thing for both token systems. You only need the global HTMX header hook for non-form triggers such as row-level toggle and delete buttons.

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.

For Grails plugins, see the matching project on the apache org or the plugin’s own GitHub repository.

Files touched: none.