You already have Java endpoints, input validation, and HTML templates. Now you want inline editing and live search without maintaining a separate application in the browser.
HTMX lets an HTML element send an HTTP request and replace a selected part of the page with the returned HTML. Quarkus handles the routes and persistence; Qute renders the page and fragments. Web Bundler brings the frontend dependency into the Maven build.
We will build a small task manager with create, edit, delete, search, and validation. It uses a few inline JavaScript event handlers for error feedback, while keeping application state and rendering on the server. You do not need a separate Node.js build for this example.
Start with the project
The source project pins Java 21, Quarkus 3.30.4, Web Bundler 2.0.4, and HTMX 2.0.8. Start there for the versioned baseline. The edit-form error handler and trimmed-title validation below are corrections to that baseline; apply them when using the repository copy.
To scaffold the same application shape with your supported Quarkus release:
quarkus create app com.example:htmx-tasks --extension=rest,web-bundler,rest-qute,hibernate-orm-panache,hibernate-validator,jdbc-h2
cd htmx-tasks
The example uses H2 for local development. It does not implement task completion, authentication, or a production persistence configuration.
Load HTMX through Maven
Add this dependency to pom.xml:
<dependency>
<groupId>org.mvnpm</groupId>
<artifactId>htmx.org</artifactId>
<version>2.0.8</version>
<scope>provided</scope>
</dependency>
Web Bundler consumes this frontend dependency at build time. Import it from src/main/resources/web/app.js:
import 'htmx.org';
In src/main/resources/templates/base.html, insert the bundle once:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title ?: 'Task Manager'}</title>
{#bundle/}
</head>
<body>
{#insert /}
</body>
</html>
{#bundle /} inserts the generated asset tags for the app entry point. Importing HTMX in app.js and then adding another direct script tag can load it twice. The Web Bundler guide explains the entry point and generated assets.
Check one fragment before building the forms
The repository's initial page has a button with hx-get="/test" and hx-target="#result". Its endpoint returns a paragraph. Run:
./mvnw quarkus:dev
Open http://localhost:8080 and click the button. The paragraph should appear in the target element without navigating away. In browser developer tools, check the /test response and the bundled script if it does not.
That request establishes the pattern for every interaction below: send an HTTP request, render an HTML fragment, and choose where it goes.
Define the task model
Create src/main/java/com/example/model/Task.java:
package com.example.model;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Entity;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@Entity
public class Task extends PanacheEntity {
@NotBlank
@Size(min = 3, max = 100)
public String title;
public boolean completed;
}
The completed field exists in the model, but this walkthrough does not add a toggle for it. The title is validated on the server before persistence. Browser input constraints give earlier feedback but cannot protect an endpoint from direct requests.
Render a row and an edit form
Qute's checked-template class uses the TaskResource template directory. Create src/main/resources/templates/TaskResource/taskItem.html:
<li id="task-{task.id}">
<span hx-get="/tasks/{task.id}/edit" hx-target="#task-{task.id}" hx-swap="outerHTML">
{task.title}
</span>
<button hx-delete="/tasks/{task.id}" hx-confirm="Are you sure?" hx-target="#task-{task.id}" hx-swap="outerHTML">
Delete
</button>
</li>
Clicking the title requests an edit form and replaces the entire row. Deleting requests the same row as its target; a successful empty 200 response removes it through the outerHTML swap.
Create taskEditForm.html in the same directory:
<li id="task-{task.id}">
<form hx-put="/tasks/{task.id}" hx-target="#task-{task.id}" hx-swap="outerHTML"
hx-on::response-error="if(event.detail.xhr.status === 422) { this.querySelector('.edit-errors').innerHTML = event.detail.xhr.responseText; }">
<div class="edit-errors" role="alert"></div>
<input type="text" name="title" value="{task.title}" required minlength="3" maxlength="100" autofocus>
<button type="submit">Save</button>
<button type="button" hx-get="/tasks/{task.id}/view" hx-target="#task-{task.id}" hx-swap="outerHTML">
Cancel
</button>
</form>
</li>
HTMX does not swap a 422 response by default. The handler reads the server-rendered error fragment into .edit-errors, preserving the form and the entered value. It only accepts the 422 path; unrelated failures should not be treated as validation messages. Qute escapes the error text rendered by the template.
For search results, create taskList.html:
{#for task in tasks}
{#include TaskResource/taskItem task=task /}
{/for}
Both forms use taskFormErrors.html:
<div class="errors">
{#for error in errors}
<p>{error}</p>
{/for}
</div>
Connect the main page
Create TaskResource/tasks.html:
{#include base title="Tasks"}
<h1>Task Manager</h1>
<form hx-post="/tasks" hx-target="#task-list" hx-swap="beforeend"
hx-on::after-request="if(event.detail.successful) { this.reset(); document.getElementById('form-errors').innerHTML = ''; }"
hx-on::response-error="if(event.detail.xhr.status === 422) { document.getElementById('form-errors').innerHTML = event.detail.xhr.responseText; }">
<div id="form-errors" role="alert"></div>
<input type="text" name="title" placeholder="New task" required minlength="3" maxlength="100">
<button>Add Task</button>
</form>
<input type="search" name="q" placeholder="Search tasks" hx-get="/tasks/search" hx-trigger="keyup changed delay:300ms"
hx-target="#task-list">
<ul id="task-list">
{#for task in tasks}
{#include taskItem task=task /}
{/for}
</ul>
{/include}
The create form appends a returned row with beforeend. It resets only after a successful request. The search field waits 300 milliseconds after a changed keystroke before requesting a new list. hx-target identifies the element to update; hx-swap chooses the replacement behavior.
The two hx-on attributes are small JavaScript handlers. The server still owns the stored tasks and validation rules. See HTMX response handling for the default status-code behavior.
Return the next fragment from Java
Create src/main/java/com/example/TaskResource.java:
package com.example;
import java.util.List;
import com.example.model.Task;
import io.quarkus.qute.CheckedTemplate;
import io.quarkus.qute.TemplateInstance;
import jakarta.transaction.Transactional;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path("/tasks")
public class TaskResource {
@CheckedTemplate
public static class Templates {
public static native TemplateInstance tasks(List<Task> tasks);
public static native TemplateInstance taskItem(Task task);
public static native TemplateInstance taskEditForm(Task task);
public static native TemplateInstance taskList(List<Task> tasks);
public static native TemplateInstance taskFormErrors(List<String> errors);
}
@GET
@Produces(MediaType.TEXT_HTML)
public TemplateInstance list() {
List<Task> tasks = Task.listAll();
return Templates.tasks(tasks);
}
@POST
@Transactional
@Produces(MediaType.TEXT_HTML)
public Response create(@FormParam("title") String title) {
// Validate
if (title == null || title.trim().length() < 3) {
return Response.status(422)
.entity(Templates.taskFormErrors(
List.of("Title must be at least 3 characters")))
.build();
}
if (title.trim().length() > 100) {
return Response.status(422)
.entity(Templates.taskFormErrors(
List.of("Title must be at most 100 characters")))
.build();
}
// Create task
Task task = new Task();
task.title = title.trim();
task.completed = false;
task.persist();
return Response.ok(Templates.taskItem(task)).build();
}
@GET
@Path("/{id}/view")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance view(@PathParam("id") Long id) {
Task task = Task.findById(id);
if (task == null) {
throw new NotFoundException();
}
return Templates.taskItem(task);
}
@GET
@Path("/{id}/edit")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance editForm(@PathParam("id") Long id) {
Task task = Task.findById(id);
if (task == null) {
throw new NotFoundException();
}
return Templates.taskEditForm(task);
}
@PUT
@Path("/{id}")
@Transactional
@Produces(MediaType.TEXT_HTML)
public Response update(@PathParam("id") Long id,
@FormParam("title") String title) {
Task task = Task.findById(id);
if (task == null) {
throw new NotFoundException();
}
// Validate
if (title == null || title.trim().length() < 3) {
return Response.status(422)
.entity(Templates.taskFormErrors(
List.of("Title must be at least 3 characters")))
.build();
}
if (title.trim().length() > 100) {
return Response.status(422)
.entity(Templates.taskFormErrors(
List.of("Title must be at most 100 characters")))
.build();
}
task.title = title.trim();
task.persist();
return Response.ok(Templates.taskItem(task)).build();
}
@DELETE
@Path("/{id}")
@Transactional
public Response delete(@PathParam("id") Long id) {
boolean deleted = Task.deleteById(id);
if (!deleted) {
throw new NotFoundException();
}
return Response.ok().build();
}
@GET
@Path("/search")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance search(@QueryParam("q") String query) {
List<Task> results;
if (query == null || query.isBlank()) {
results = Task.listAll();
} else {
results = Task.list("lower(title) like lower(?1)",
"%" + query.trim() + "%");
}
return Templates.taskList(results);
}
}
Creation and update trim the title before checking the 3–100 character range. Java's String.length() counts UTF-16 code units; choose a different length convention explicitly if your product requires code points or user-perceived characters.
A rejected update returns before changing the entity. A missing task returns 404. Successful create and update requests return the same row template, while search returns only the list entries.
Verify the interaction and the HTTP boundary
Open http://localhost:8080/tasks and create a task. Edit it, save a new title, cancel another edit, search for the title, and delete the row. Watch which DOM element changes after each response.
Then submit a title that has enough raw characters for the browser constraint but becomes too short after trimming, such as a. Both create and edit should show the 422 error. The edit form should retain the entered value and leave the stored title unchanged.
Check the server directly too:
curl --include --data-urlencode 'title=ab' http://localhost:8080/tasks
curl --include --data-urlencode 'title=Read the API contract' http://localhost:8080/tasks
The first request should return 422 with an HTML error. The second should return 200 and a task row. Use that row's actual ID to check update and delete:
curl --include --request PUT --data-urlencode 'title=ab' http://localhost:8080/tasks/ACTUAL_ID
curl --include http://localhost:8080/tasks/ACTUAL_ID/view
curl --include --request DELETE http://localhost:8080/tasks/ACTUAL_ID
After the rejected update, the view should still contain the original title. The delete response should be 200 with an empty body. A later view should return 404.
Keep the request path easy to inspect
The same row template serves the initial page, creation, editing, and search. That keeps the browser update close to the server behavior you can test and inspect. A larger application will still need its normal access controls, persistence choices, and operational checks.
For a Java team already comfortable with HTTP and templates, this is a manageable way to add interactivity one fragment at a time.
Which interaction would you move to a server-rendered fragment first: inline editing, search, or validation feedback?
Adapted from my original Main Thread article with AI assistance for editing. Cover illustration generated with AI.
Top comments (0)