A Quick Guide to htmx in Kotlin

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:

Image presents a screenshot of result application created in Kotlin and htmx.

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.

Video Tutorial

If you prefer video content, then check out my video:

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:

Image is a screenshot of a plain HTML application generated with Kotlin HTML DSL in Ktor

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:

Image is a screenshot from htmx POST invocation with invalid target.

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:

Image is a placeholder used ad an example in our project and shows Codersee logo.

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.

Leave a Reply

Your email address will not be published. Required fields are marked *