htmx on Domino with Jakarta EE
Mon Nov 10 14:51:29 EST 2025
Recently, the estimable Heiko Voigt has been writing a blog series on htmx with Domino. Since this post will build on the current state of that directly, I recommend you read each of those posts, which will give you an explanation of what htmx is and some examples of using it with Domino data.
I've been meaning to kick the tires on htmx for a while now, and this was a good excuse. It's a neat tool and just so happens to dovetail perfectly with the strengths of the XPages Jakarta EE project. So: let's see how that shakes out!
For starters, I made an NSF much like the state in part 4 of Heiko's blog series - a trip tracker with the same fields and a view. The implementation after that diverges, though.
Data Access
To get to the data, I created a NoSQL entity to represent the trips, using the convenient syntax of records:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | package model; import java.time.LocalDate; import java.util.stream.Stream; import org.openntf.xsp.jakarta.nosql.mapping.extension.DominoRepository; import org.openntf.xsp.jakarta.nosql.mapping.extension.ViewEntries; import jakarta.nosql.Column; import jakarta.nosql.Entity; import jakarta.nosql.Id; @Entity public record Trip( @Id String unid, @Column("Date") LocalDate date, @Column("User") String user, @Column("Start") String start, @Column("Destination") String destination, @Column("KM") int km, @Column("Expenses") double expenses, @Column("Description") String description ) { public interface Repository extends DominoRepository<Trip, String> { @ViewEntries("Trips") Stream<Trip> listTrips(); } } |
With this, the listTrips() method is pretty equivalent to the view reading that Heiko's example does, just handled by the framework.
Pages
For the front end, we'll start with a basic Jakarta Pages (JSP) page. This could really be plain HTML, since it has almost no logic to it, but it's nice to stick with the same tool as we'll use later. It's pretty thin, with the CSS and JS being the same ones from Heiko's second post.
This file is "WebContent/WEB-INF/views/home.jsp":
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | <%@page contentType="text/html" pageEncoding="UTF-8" trimDirectiveWhitespaces="true" %> <!DOCTYPE html> <html> <head> <title>htmx Tester</title> <link rel="stylesheet" href="${pageContext.request.contextPath}/htmx-main.css" /> <script type="text/javascript" src="${pageContext.request.contextPath}/htmx.min.js"></script> </head> <body> <header id="main-header"> <h1>htmx and HCL Domino w/ Jakarta EE</h1> </header> <main> <div hx-get="${mvc.basePath}/apihandler/trips" hx-trigger="load"> </div> </main> </body> </html> |
There are a few things to note that differ from the original:
${pageContext.request.contextPath}is used to get the path to the app. In XPages, a preceding "/" in an element like<xp:styleSheet/>will imply this, but we need to bring our own here. It'll be something like "/dev/htmx.nsf" here.${mvc.basePath}is a handy way to reference the base path of the REST app. It'll be something like "/dev/htmx.nsf/xsp/app" here.- Since we'll have a REST endpoint specifically for the Trip objects instead of an adaptable any-view one, the hx-get URL doesn't include the replica ID.
In Heiko's post, he uses a JSON extension to htmx to allow calling REST endpoints that emit JSON and smoothly translating those to HTML on the client side. Since Jakarta Pages is a perfectly-good HTML-templating engine in its own right, though, we'll do that work on the server and skip the extension.
This file is "WebContent/WEB-INF/views/trips-table.jsp":
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | <%@page contentType="text/html" pageEncoding="UTF-8" trimDirectiveWhitespaces="true" %> <%@taglib prefix="c" uri="jakarta.tags.core" %> <table id="mytable"> <thead> <tr> <th>Date</th> <th>User ID</th> <th>Start</th> <th>Destination</th> <th>KM</th> <th>Expenses</th> <th>Description</th> </tr> </thead> <tbody id="tbody"> <c:forEach items="${trips}" var="trip"> <tr> <td><c:out value="${trip.date}"/></td> <td><c:out value="${trip.user}"/></td> <td><c:out value="${trip.start}"/></td> <td><c:out value="${trip.destination}"/></td> <td><c:out value="${trip.km}"/></td> <td><c:out value="${trip.expenses}"/></td> <td><c:out value="${trip.description}"/></td> </tr> </c:forEach> </tbody> </table> |
You can see that the concept is basically the same. Instead of having htmx do the interpolation, we'll use Pages's loops. That <c:forEach/> is basically <xp:repeat/> from XPages, while <c:out/> is basically <xp:text/>.
Controller Glue
Now that we have the data model and the HTML/htmx view, we'll tie them together with some controller classes. The first is the one that serves up the home page, and it's lean:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | package controller; import jakarta.mvc.Controller; import jakarta.mvc.View; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; @Path("/") @Controller public class HomeController { @GET @View("home.jsp") public void get() { } } |
With this in place, visiting "dev/htmx.nsf/xsp/app" will load up index.jsp from above.
The controller class for our API is a bit more complicated:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | package controller; import jakarta.inject.Inject; import jakarta.mvc.Controller; import jakarta.mvc.Models; import jakarta.mvc.View; import jakarta.ws.rs.GET; import jakarta.ws.rs.Path; import model.Trip; @Path("apihandler") @Controller public class ApiController { @Inject private Trip.Repository tripRepository; @Inject private Models models; @Path("trips") @GET @View("trips-table.jsp") public void listTrips() { models.put("trips", tripRepository.listTrips().toList()); } } |
Here, we use CDI to @Inject the repository for our Trip entity. The Models object comes from MVC, and it's what we use to populate the data that the .jsp file will need for its loop. You can then see the method where we call "dev/htmx.nsf/xsp/app/apihandler/trips" - it reads in the Trip objects and puts them in the "trips" Models field. That's how the <c:forEach/> in the Pages file gets its hands on it.
And with that, we're done! The result looks just the same as Heiko's example, and that was the goal. This was good to prove to myself that htmx is a great match here, and I'm tempted to try it in a larger project now.