Codersee
Kotlin on the backend
Codersee
Kotlin on the backend
In this tutorial, we will learn the most important htmx concepts and create a basic application with Kotlin HTML DSL and Tailwind CSS.
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.
If you prefer video content, then check out my video:
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:
DELETE /users/7a9079f0-c5a2-45d0-b4ae-e304b6908787
request is madeAnd 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 😉
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.
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.
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
.
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?
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.
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 😉
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:
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).
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()) } } }
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:
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 😀
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
attributeclosest
, next
, previous
<CSS selector>
(like closest div
)- to target the closest ancestor element or itselffind <CSS selector
– to target the first child descendant element that matches the given CSS selectorAs 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:
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 elementouterHTML
– replaces the entire target element with the returned contentafterbegin
– prepends the content before the first child inside the targetbeforebegin
– prepends the content before the target in the target’s parent elementbeforeend
– appends the content after the last child inside the targetafterend
– appends the content after the target in the target’s parent elementdelete
– deletes the target element regardless of the responsenone
– 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! 🙂
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!
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 errorSo, 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😉
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 😉
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😄
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" } }
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.
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 🙂
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😉
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.