A Quick Guide to htmx in Kotlin

To be more specific, we will see a step-by-step process of writing HTML with typesafe Kotlin DSL, integrating htmx, and handling requests with Ktor.

Eventually, we will get the below user table:

But before we start, just a short disclaimer: although the main focus for this tutorial is the htmx / Kotlin / Ktor combination, I decided to bring Tailwind CSS to the project with the help of Material Tailwind components. This way, I wanted to showcase the code that is closer to real-life scenarios. Not a next, plain HTML example.

So, although I tried my best, please keep in mind that the HTML part may need some more love when adapting

Note: if you enjoy this content and would like to learn Ktor step-by-step, then check out my Ktor Server Pro course.

What is htmx?

If you are here, then there is a high chance you’ve been looking for a Kotlin & htmx combination, so you already know what it is.

Nevertheless, so we are all on on the same page:

It is a library that allows you to access modern browser features directly from HTML, rather than using javascript.

And we could add plenty of other things here, like the fact that it is small, dependency-free, and allows us to use AJAX, CSS Transitions, WebSockets, and so on.

But, IMO, the most important thing from the practical standpoint is that we can use attributes in HTML, and the library will do the “magic” for us:

<td>
<button hx-delete=»/users/7a9079f0-c5a2-45d0-b4ae-e304b6908787″ hx-swap=»outerHTML» hx-target=»closest tr»>

… the rest

The above following snippet means that when we click the button:

the DELETE /users/7a9079f0-c5a2-45d0-b4ae-e304b6908787 request is made

the closest tr element is replaced with the HTML response we receive

And I believe this is all we need to know for now.

Again, a short note from my end: the htmx documentation is a great resource, and I will refer a lot to it throughout this course to not reinvent the wheel. But at the same time, I want to deliver you a fully-contained article so that you don’t need to jump between pages

Generate Ktor Project

As the first step, let’s quickly generate a new Ktor project using https://start.ktor.io/:

As we can see, the only plugin we need to select is the Kotlin HTML DSL (this way, the Routing plugin is added, too).

Regarding the config, we are going to use Ktor 3.1.1 with Netty, YAML config, and without a version catalog. But, of course, feel free to adjust it here according to your needs.

With that done, let’s download the project and import it to our IDE.

Create User Repository

Following, let’s add the repository package and introduce a simple, in-memory user repository:

data class User(
val id: String = UUID.randomUUID().toString(),
val firstName: String,
val lastName: String,
val enabled: Boolean,
val createdAt: LocalDateTime = LocalDateTime.now(),
)

class UserRepository {

private val users = mutableListOf(
User(firstName = «Jane», lastName = «Doe», enabled = true),
User(firstName = «John», lastName = «Smith», enabled = true),
User(firstName = «Alice», lastName = «Johnson», enabled = false),
User(firstName = «Bob», lastName = «Williams», enabled = true),
)

fun create(firstName: String, lastName: String, enabled: Boolean): User =
User(firstName = firstName, lastName = lastName, enabled = enabled)
.also(users::add)

fun findAll(): List<User> = users

fun delete(id: String): Boolean = users.removeIf { it.id == id }
}

As we can see, nothing spectacular. Just 3 functions responsible for creating, searching, and deleting users.

Return HTML Response in Ktor

Following, let’s add the routing package and Routing.kt:

fun Application.configureRouting(userRepository: UserRepository) {
routing {

get(«/») {
call.respondHtml {
renderIndex(userRepository)
}
}
}

}

And update the main Application.kt to incorporate those changes:

fun Application.module() {
val userRepository = UserRepository()

configureRouting(userRepository)
}

In a moment, we will add the renderIndex function, but for now, let’s focus on the above.

Long story short, the above code instructs the Ktor server to respond with the HTML response whenever it reaches the root path. By default, the localhost:8080.

Generate HTML page with Kotlin DSL

With that done, we have everything we need to start returning HTML responses. So in this section, we will prepare the baseline for htmx.

Note: if you feel that continuous server restarting is painful, please check out how to enable auto-reload in Ktor?

Render Homepage

As the first step, let’s add the html package and the renderIndex function in Index.kt:

import com.codersee.repository.UserRepository
import kotlinx.html.*

fun HTML.renderIndex(userRepository: UserRepository) {
body {
div {
insertHeader()
}

}
}

private fun FlowContent.insertHeader() {
h5 {
+»Users list»
}
}

At this point, such a structure is overengineering. Nevertheless, our codebase is about to grow quickly in this tutorial, so we rely on Kotlin extension functions from the very beginning.

And as we can see, we use the HTML that represents the root element of an HTML document. Inside it, we can define the structure in a type-safe manner, thanks to the Kotlin DSL feature. For the inner tags, we can use the FlowContent that is the marker interface for plenty of classes representing HTML tags, like div, headers, or the body.

And before we rerun the application, we must add the necessary import in Routing.kt:

import com.codersee.repository.html.renderIndex

With that done, let’s rerun the application and verify that everything is working.

Insert HTML Form

Following, let’s use the HTML DSL to define the user form.

As the first step, let’s add the Form.kt to inside the html package:

import kotlinx.html.*

fun FlowContent.insertUserForm() {
div {
form {
div {
div {
label {
htmlFor = «first-name»
+»First Name»
}
input {
type = InputType.text
name = «first-name»
id = «first-name»
placeholder = «First Name»
}
}
div {
label {
htmlFor = «last-name»
+»Last Name»
}
input {
type = InputType.text
name = «last-name»
id = «last-name»
placeholder = «Last Name»
}
}
div {
label {
+»Account enabled»
}
div {
div {
input {
type = InputType.radio
name = «enabled-radio»
id = «radio-button-1»
value = «true»
}
label {
htmlFor = «radio-button-1»
+»Yes»
}
}
div {
input {
type = InputType.radio
name = «enabled-radio»
id = «radio-button-2»
value = «false»
checked = true
}
label {
htmlFor = «radio-button-2»
+»No»
}
}
}
}
div {
button {
+»Add user»
}
}
}
}
}
}

As we can see, the Kotlin DSL allows us to define everything in a neat, structured manner. And although I had a chance to use it in various places, with HTML, it feels so…natural. We write the code pretty similar to HTML.

Of course, before heading to the next part, let’s make use of our function:

fun HTML.renderIndex(userRepository: UserRepository) {
body {
div {
insertHeader()
insertUserForm()
}

}
}

We can clearly see that the extraction was a good idea

Create User Table

As the next step, let’s add the UserTable.kt:

import com.codersee.repository.User
import kotlinx.html.*
import java.time.format.DateTimeFormatter

fun FlowContent.insertUserTable(users: List<User>) {
div {
table {
thead {
tr {
th {
+»User»
}
th {
+»Status»
}
th {
+»Created At»
}
th {}
}
}

tbody {
id = «users-table»

users.forEach { user ->
tr {
insertUserRowCells(user)
}
}
}
}
}
}

fun TR.insertUserRowCells(user: User) {
td {
div {
p {
+»${user.firstName} ${user.lastName}»
}
}
}
td {
div {
div {
val enabledLabel = if (user.enabled) «Enabled» else «Disabled»
val labelColor = if (user.enabled) «green» else «red»

span { +enabledLabel }
}
}
}
td {
p {
+user.createdAt.format(DateTimeFormatter.ofPattern(«yyyy-MM-dd HH:mm:ss»))
}
}
td {
button {
+ «Delete»

}
}
}

As we can see this time, the Kotlin HTML DSL allows us to easily generate dynamic HTML tags. We use the passed user list to create a row for every user we “persisted”. We even make the decision about the label and future color based on the list.

Again, let’s get back to Routing.kt and invoke our function:

fun HTML.renderIndex(userRepository: UserRepository) {
body {
div {
insertHeader()
insertUserForm()
insertUserTable(userRepository.findAll())
}

}
}

And although I am pretty sure it won’t become the eighth wonder of the World, our application starts looking similar to what we want to achieve:

htmx and Kotlin

With all of that done, we have everything prepared to start actually working with htmx and Kotlin.

Again, you will see a lot of references taken from the docs (which I encourage you to visit after this tutorial).

Import

As the first step, let’s import htmx.

Again, it is nothing else than the JavaScript library, so the only thing we need is to add it inside the script block of our HTML.

And the easiest way is to simply fetch it from their CDN and put it inside the Kotlin script DSL block:

fun HTML.renderIndex(userRepository: UserRepository) {
head {
script {
src = «https://unpkg.com/htmx.org@2.0.4»
}
}
body {
div {
insertHeader()
insertUserForm()
insertUserTable(userRepository.findAll())
}

}
}

AJAX Requests – Create User

As we saw in the very beginning, htmx allows us to define requests with attributes.

To be more specific, we can use the following attributes:

hx-get

hx-post

hx-put

hx-patch

hx-delete

And, long story short, when the element is triggered, an AJAX request is made to the specified URL.

So, let’s update our form then:

fun FlowContent.insertUserForm() {
div {
form {
attributes[«hx-post»] = «/users»

… the rest

This way, our html now contains:

<form hx-post=»/users»>

And when we hit the Add user button, we can see that the request is triggered (but, it results in 404 response given we have no handler).

So, let’s add the endpoint responsible for user creation:

fun Application.configureRouting(userRepository: UserRepository) {
routing {

get(«/») {
call.respondHtml {
renderIndex(userRepository)
}
}

route(«/users») {

post {
val formParams = call.receiveParameters()
val firstName = formParams[«first-name»]!!
val lastName = formParams[«last-name»]!!
val enabled = formParams[«enabled-radio»]!!.toBoolean()

val createdItem = userRepository.create(firstName, lastName, enabled)

val todoItemHtml = createHTML().tr { insertUserRowCells(createdItem) }

call.respondText(
todoItemHtml,
contentType = ContentType.Text.Html,
)
}
}
}

}

At this point, it should not be a surprise, but we can see that in Ktor, we can do that quite easily.

Our code snippet will read the form parameters sent from the browser, “create” a new user, and return a 200 OK response with:

<tr>
<td>
<div>
<p>Admiral Jahas</p>
</div>
</td>
<td>
<div>
<div><span>Disabled</span></div>
</div>
</td>
<td>
<p>2025-03-29 08:16:40</p>
</td>
<td><button>Delete</button></td>
</tr>

The important thing to mention here is that respondHtml requires us to respond with whole body! So, to bypass that, we use the respondText function and set the content type as HTML.

However, when we open up the browser, we can see this:

And I am pretty sure that is not what we wanted

htmx Target

Lesson one: if we want to instruct htmx to load the response into a different element than the one that made the request, we must use the hx-target attribute that takes the CSS selector, or:

this keyword- to refer to the element with hx-target attribute

closest, next, previous <CSS selector> (like closest div)- to target the closest ancestor element or itself

find <CSS selector – to target the first child descendant element that matches the given CSS selector

As a proof, let’s take a look at what happened previously:

As we can see, the table row was inserted inside the form. And that does not make sense, at all.

So, to fix that, let’s target the table tbody instead:

fun FlowContent.insertUserForm() {
div {
form {
attributes[«hx-post»] = «/users»
attributes[«hx-target»] = «#users-table»

As a result, all the other rows are deleted, but it seems to be closer to what we want:

Swapping in htmx

Next lesson: by default, htmx replaces the innerHTML of the target element.

So, in our case, the user was added successfully. We can even refresh the page and see that the array contains all created users. However, we have not defined the hx-swap so the tbody inner HTML was deleted, and our returned one was inserted instead.

So, we must add the hx-swap with one of the following values:

innerHTML– puts the content inside the target element

outerHTML– replaces the entire target element with the returned content

afterbegin– prepends the content before the first child inside the target

beforebegin– prepends the content before the target in the target’s parent element

beforeend– appends the content after the last child inside the target

afterend– appends the content after the target in the target’s parent element

delete– deletes the target element regardless of the response

none– does not append content from response (Out of Band Swaps and Response Headers will still be processed)

And in our case, the beforeend is the one we should pick to append the created user at the end of the list:

fun FlowContent.insertUserForm() {
div {
form {
attributes[«hx-post»] = «/users»
attributes[«hx-target»] = «#users-table»
attributes[«hx-swap»] = «beforeend»

When we restart the app, everything works fine!

Dynamic htmx Tags in Kotlin

At this point, we know how to display and add new users with htmx. So, let’s learn how to delete them.

As the first step, let’s prepare a Ktor handler inside the route(«/users») for the DELETE request:

delete(«/{id}») {
val id = call.parameters[«id»]!!

userRepository.delete(id)

call.respond(HttpStatusCode.OK)
}

With that code, whenever a DELETE /users/{some-id} is made, we remove the user from our list and return 200 OK.

Important lesson here: for simplicity, we return 200 OK (and not 204 No Content), because by default, htmx ignores successful responses other than 200.

Following, let’s update our button:

td {
button {
attributes[«hx-delete»] = «/users/${user.id}»
attributes[«hx-swap»] = «outerHTML»
attributes[«hx-target»] = «closest tr»

So, firstly, whenever we generate our button, we use Kotlin string interpolation to put the user identifier in the hx-delete attribute value. A neat and easy way to achieve that with Kotlin.

When it comes to swapping, we want to find the closest tr parent and swap the entire element with the response. And as the response contains nothing, it will be simply removed

After we rerun the application, we will see everything working perfectly fine!

Error Handling with Ktor and htmx

Following, let’s learn how we can handle any Ktor error response in htmx.

For that purpose, let’s update the POST handler in Ktor:

post {
val formParams = call.receiveParameters()
val firstName = formParams[«first-name»]!!
val lastName = formParams[«last-name»]!!
val enabled = formParams[«enabled-radio»]!!.toBoolean()

if (firstName.isBlank() || lastName.isBlank())
return@post call.respond(HttpStatusCode.BadRequest)

… the rest of the code

With that validation, whenever first-name or last-name form parameter is blank, the API client receives 400 Bad Request.

After we restart the server and try to make a request without passing first or last name, we see that nothing is happening. No pop-ups, alerts, nothing. The only indication that the request is actually made is thenetwork tab of our browser.

Well, unfortunately (or fortunately?), htmx does not provide any handling out-of-the-box.

But, it throws two events:

htmx:responseError– in the event of an error response from the server, like 400 Bad Request

htmx:sendError– in case of connection error

So, let’s add a tiny bit of JS in Kotlin, then:

private fun BODY.insertErrorHandlingScripts() {
script {
+»»»
document.body.addEventListener(‘htmx:responseError’, function(evt) {
alert(‘An error occurred! HTTP status:’ + evt.detail.xhr.status);
});

document.body.addEventListener(‘htmx:sendError’, function(evt) {
alert(‘Server unavailable!’);
});
«»».trimIndent()
}
}

And let’s add this script at the end of the body when rendering the homepage:

fun HTML.renderIndex(userRepository: UserRepository) {
head {
script {
src = «https://unpkg.com/htmx.org@2.0.4»
}
}
body {
div {
insertHeader()
insertUserForm()
insertUserTable(userRepository.findAll())
}

insertErrorHandlingScripts()
}
}

Excellent! From now on, whenever the API client receives an error response, the alert is displayed. Moreover, if we turn off our server, we will see the error response, too.

And basically, that is all for the htmx part with Kotlin. From now on, we are going to work on the styling of our application

Returning Images in Ktor

Before we head to the Tailwind CSS part, let’s learn one more thing in Ktor: static responses handling.

So, let’s put the below image in the resources -> img directory:

And let’s add this image as a placeholder to each row in our table:

fun TR.insertUserRowCells(user: User) {
td {
div {
img {
src = «/img/placeholder.png»
}

When we rerun the application, we can see that it does not work.

Well, to fix that, we must instruct Ktor to serve our resources as static content:

fun Application.configureRouting(userRepository: UserRepository) {
routing {
staticResources(«/img», «img»)

This time, when we restart the application, we see that placeholders are working fine.

And we will style them in a moment

Styling With Tailwind CSS

At this point, we have a fully working Kotlin and htmx integration.

So, if we already did something else than the JSON response, let’s make it nice

Import Tailwind

Just like with htmx, let’s use the CDN to import Tailwind to the project:

fun HTML.renderIndex(userRepository: UserRepository) {
head {
script {
src = «https://unpkg.com/htmx.org@2.0.4»
}
script {
src = «https://unpkg.com/@tailwindcss/browser@4»
}
}

Update Index

Then, let’s navigate to the Index.kt and add adjustments:

fun HTML.renderIndex(userRepository: UserRepository) {
head {
script {
src = «https://unpkg.com/htmx.org@2.0.4»
}
script {
src = «https://unpkg.com/@tailwindcss/browser@4»
}
}
body {
div {
classes = setOf(«m-auto max-w-5xl w-full overflow-hidden»)

insertHeader()
insertUserForm()
insertUserTable(userRepository.findAll())
}

insertErrorHandlingScripts()
}
}

private fun FlowContent.insertHeader() {
h5 {
classes =
setOf(«py-8 block font-sans text-xl antialiased font-semibold leading-snug tracking-normal text-blue-gray-900»)

+»Users list»
}
}

private fun BODY.insertErrorHandlingScripts() {
script {
+»»»
document.body.addEventListener(‘htmx:responseError’, function(evt) {
alert(‘An error occurred! HTTP status:’ + evt.detail.xhr.status);
});

document.body.addEventListener(‘htmx:sendError’, function(evt) {
alert(‘Server unavailable!’);
});
«»».trimIndent()
}
}

As we can see, we can use the classes in Kotlin HTML DSL to prive classes names as a Set of String values:

classes = setOf(“m-auto max-w-5xl w-full overflow-hidden”)

In my case, I prefer simply copy-pasting those values instead of separating them with colons.

Refactor Form

Then, let’s update the Form.kt:

fun FlowContent.insertUserForm() {
div {
classes = setOf(«mx-auto w-full»)

form {
attributes[«hx-post»] = «/users»
attributes[«hx-target»] = «#users-table»
attributes[«hx-swap»] = «beforeend»

div {
classes = setOf(«-mx-3 flex flex-wrap»)

div {
classes = setOf(«w-full px-3 sm:w-1/4»)

label {
classes = setOf(«mb-3 block text-base font-medium text-[#07074D]»)

htmlFor = «first-name»
+»First Name»
}
input {
classes =
setOf(«w-full rounded-md border border-[#e0e0e0] bg-white py-3 px-6 text-base font-medium text-[#6B7280] outline-none focus:border-[#6A64F1] focus:shadow-md»)

type = InputType.text
name = «first-name»
id = «first-name»
placeholder = «First Name»
}
}
div {
classes = setOf(«w-full px-3 sm:w-1/4»)

label {
classes = setOf(«mb-3 block text-base font-medium text-[#07074D]»)

htmlFor = «last-name»
+»Last Name»
}
input {
classes =
setOf(«w-full rounded-md border border-[#e0e0e0] bg-white py-3 px-6 text-base font-medium text-[#6B7280] outline-none focus:border-[#6A64F1] focus:shadow-md»)

type = InputType.text
name = «last-name»
id = «last-name»
placeholder = «Last Name»
}
}
div {
classes = setOf(«w-full px-3 sm:w-1/4»)

label {
classes = setOf(«mb-3 block text-base font-medium text-[#07074D]»)

+»Account enabled»
}
div {
classes = setOf(«flex items-center space-x-6 pt-3»)

div {
classes = setOf(«flex items-center»)

input {
classes = setOf(«h-5 w-5»)

type = InputType.radio
name = «enabled-radio»
id = «radio-button-1»
value = «true»
}
label {
classes = setOf(«pl-3 text-base font-medium text-[#07074D]»)

htmlFor = «radio-button-1»
+»Yes»
}
}
div {
classes = setOf(«flex items-center»)

input {
classes = setOf(«h-5 w-5»)

type = InputType.radio
name = «enabled-radio»
id = «radio-button-2»
value = «false»
checked = true
}
label {
classes = setOf(«pl-3 text-base font-medium text-[#07074D]»)

htmlFor = «radio-button-2»
+»No»
}
}
}
}
div {
classes = setOf(«w-full px-3 sm:w-1/4 pt-8»)

button {
classes =
setOf(«cursor-pointer rounded-md bg-slate-800 py-3 px-8 text-center text-base font-semibold text-white outline-none»)

+»Add user»
}
}
}
}
}
}

Similarly, we don’t change anything in here apart from adding a bunch of classes. A whooooole bunch of classes

Modify User Table

As the last step, let’ apply changes to our user table:

fun FlowContent.insertUserTable(users: List<User>) {
div {
classes = setOf(«px-0 overflow-scroll»)

table {
classes = setOf(«w-full mt-4 text-left table-auto min-w-max»)

thead {
tr {
th {
classes = setOf(«p-4 border-y border-blue-gray-100 bg-blue-gray-50/50»)
+»User»
}
th {
classes = setOf(«p-4 border-y border-blue-gray-100 bg-blue-gray-50/50»)
+»Status»
}
th {
classes = setOf(«p-4 border-y border-blue-gray-100 bg-blue-gray-50/50»)
+»Created At»
}
th {
classes = setOf(«p-4 border-y border-blue-gray-100 bg-blue-gray-50/50»)
}
}
}

tbody {
id = «users-table»

users.forEach { user ->
tr {
insertUserRowCells(user)
}
}
}
}
}
}

fun TR.insertUserRowCells(user: User) {
td {
classes = setOf(«p-4 border-b border-blue-gray-50»)

div {
classes = setOf(«flex items-center gap-3»)

img {
classes = setOf(«relative inline-block h-9 w-9 !rounded-full object-cover object-center»)

src = «/img/placeholder.png»
}

p {
classes = setOf(«block font-sans text-sm antialiased font-normal leading-normal text-blue-gray-900»)

+»${user.firstName} ${user.lastName}»
}
}
}
td {
classes = setOf(«p-4 border-b border-blue-gray-50»)

div {
classes = setOf(«w-max»)

div {
val enabledLabel = if (user.enabled) «Enabled» else «Disabled»
val labelColor = if (user.enabled) «green» else «red»

classes =
setOf(«relative grid items-center px-2 py-1 font-sans text-xs font-bold text-black-900 uppercase rounded-md select-none whitespace-nowrap bg-$labelColor-500/20»)

span { +enabledLabel }
}
}
}
td {
classes = setOf(«p-4 border-b border-blue-gray-50»)

p {
classes = setOf(«block font-sans text-sm antialiased font-normal leading-normal text-blue-gray-900»)

+user.createdAt.format(DateTimeFormatter.ofPattern(«yyyy-MM-dd HH:mm:ss»))
}
}
td {
classes = setOf(«p-4 border-b border-blue-gray-50»)

button {
classes =
setOf(«cursor-pointer relative h-10 max-h-[40px] w-10 max-w-[40px] select-none rounded-lg text-center align-middle font-sans text-xs font-medium uppercase text-gray-900 transition-all hover:bg-gray-900/10 active:bg-gray-900/20 disabled:pointer-events-none disabled:opacity-50 disabled:shadow-none»)
attributes[«hx-delete»] = «/users/${user.id}»
attributes[«hx-swap»] = «outerHTML»
attributes[«hx-target»] = «closest tr»

unsafe {
+»»»
<span class=»absolute transform -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2″>
<svg xmlns=»http://www.w3.org/2000/svg» fill=»none» viewBox=»0 0 24 24″
stroke-width=»2″ stroke=»currentColor» class=»w-6 h-6″>
<path stroke-linecap=»round» stroke-linejoin=»round»
d=»M6 18L18 6M6 6l12 12″/>
</svg>
</span>
«»».trimIndent()
}

}
}
}

And here, apart from the CSS classes, we also added the X icon with the unsafe function from Kotlin HTML DSL:

unsafe {
+»»»
<span class=»absolute transform -translate-x-1/2 -translate-y-1/2 top-1/2 left-1/2″>
<svg xmlns=»http://www.w3.org/2000/svg» fill=»none» viewBox=»0 0 24 24″
stroke-width=»2″ stroke=»currentColor» class=»w-6 h-6″>
<path stroke-linecap=»round» stroke-linejoin=»round»
d=»M6 18L18 6M6 6l12 12″/>
</svg>
</span>
«»».trimIndent()
}

And voila! When we run the application now, we should see a pretty decent-looking UI

Summary

That’s all for this tutorial on how to work with htmx and Kotlin HTML DSL in Ktor.

Again, if you are tired of wasting your time looking for good Ktor resources, then check out my Ktor Server Pro course:

Over 15 hours of video content divided into over 130 lessons

Hands-on approach: together, we implement 4 actual services

top technologies: you will learn not only Ktor, but also how to integrate it with modern stack including JWT, PostgreSQL, MySQL, MongoDB, Redis, and Testcontainers.

Lastly, you can find the source code for this lesson in this GitHub repository.

The post A Quick Guide to htmx in Kotlin appeared first on Codersee — Kotlin on the backend.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *