Not every application needs a JavaScript frontend. For admin dashboards, internal tools, and simple web apps, server-side rendering with HTMX is faster to build and easier to maintain.

In this tutorial, you will build an HTMX-powered admin dashboard. You will create, list, and delete notes with dynamic updates — no page reloads, no JavaScript framework.

What is HTMX?

HTMX lets you add dynamic behavior to HTML using attributes. Instead of writing JavaScript, you add attributes like hx-get, hx-post, and hx-delete to HTML elements.

<!-- Load notes list when the page loads -->
<div hx-get="/admin/notes" hx-trigger="load">Loading...</div>

<!-- Delete a note without page reload -->
<button hx-delete="/admin/notes/1" hx-target="#note-1" hx-swap="outerHTML">
    Delete
</button>

HTMX sends the request, receives HTML from the server, and swaps it into the page. The server renders HTML, not JSON.

Dependencies

dependencies {
    implementation("io.ktor:ktor-server-html-builder:$ktorVersion")
    implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0")
}

kotlinx.html is a Kotlin DSL for generating HTML. Combined with HTMX, you get dynamic web pages without any JavaScript.

The Admin Dashboard

Here is what we will build:

/admin
├── Add Note form (hx-post)
├── Notes list (hx-get on load)
   ├── Note card with delete button (hx-delete)
   ├── Note card with delete button
   └── ...

Page Layout

Create a reusable HTML layout:

private fun HTML.adminLayout(title: String, content: BODY.() -> Unit) {
    head {
        this.title { +title }
        script(src = "https://unpkg.com/htmx.org@1.9.12") {}
        style {
            unsafe {
                raw("""
                    body { font-family: system-ui; max-width: 800px; margin: 0 auto;
                           padding: 20px; background: #1a1a2e; color: #eee; }
                    .note-card { background: #16213e; padding: 16px;
                                 margin-bottom: 12px; border-radius: 8px; }
                    button { background: #e94560; color: white; border: none;
                             padding: 8px 16px; cursor: pointer; border-radius: 4px; }
                """.trimIndent())
            }
        }
    }
    body { content() }
}

Dashboard Route

get {
    call.respondHtml(HttpStatusCode.OK) {
        adminLayout("Dashboard") {
            h1 { +"Notes Dashboard" }

            // Add note form
            form {
                attributes["hx-post"] = "/admin/notes"
                attributes["hx-target"] = "#notes-list"
                attributes["hx-swap"] = "afterbegin"
                attributes["hx-on::after-request"] = "this.reset()"

                div("form-group") {
                    label { +"Title" }
                    input(type = InputType.text, name = "title") { required = true }
                }
                div("form-group") {
                    label { +"Content" }
                    textArea { name = "content"; required = true }
                }
                button(type = ButtonType.submit) { +"Add Note" }
            }

            // Notes list - loaded via HTMX
            h2 { +"All Notes" }
            div {
                id = "notes-list"
                attributes["hx-get"] = "/admin/notes"
                attributes["hx-trigger"] = "load"
                p { +"Loading notes..." }
            }
        }
    }
}

How It Works

  1. The page loads with the form and an empty notes list
  2. hx-trigger="load" sends GET /admin/notes immediately
  3. The response (HTML fragment) replaces the loading message
  4. When you submit the form, hx-post sends a POST request
  5. The response (new note card) is inserted at the top of the list
  6. hx-on::after-request resets the form

Notes List Endpoint

This endpoint returns HTML fragments, not JSON:

get("/notes") {
    val notes = noteRepository.findAll()
    call.respondHtml(HttpStatusCode.OK) {
        body {
            if (notes.isEmpty()) {
                p { +"No notes yet. Add one above!" }
            } else {
                notes.forEach { note ->
                    noteCard(note.id, note.title, note.content, note.tags)
                }
            }
        }
    }
}

Note Card Component

private fun BODY.noteCard(id: Int, title: String, content: String, tags: List<String>) {
    div("note-card") {
        this.id = "note-$id"
        h3 { +title }
        p { +content }
        if (tags.isNotEmpty()) {
            div("tags") {
                tags.forEach { tag -> span("tag") { +tag } }
            }
        }
        button {
            attributes["hx-delete"] = "/admin/notes/$id"
            attributes["hx-target"] = "#note-$id"
            attributes["hx-swap"] = "outerHTML"
            attributes["hx-confirm"] = "Delete this note?"
            +"Delete"
        }
    }
}

Create Note Endpoint

The create endpoint receives form data (not JSON) and returns an HTML fragment:

post("/notes") {
    val params = call.receiveParameters()
    val title = params["title"] ?: ""
    val content = params["content"] ?: ""

    val note = noteRepository.create(CreateNoteRequest(title, content))

    call.respondHtml(HttpStatusCode.Created) {
        body {
            noteCard(note.id, note.title, note.content, note.tags)
        }
    }
}

Delete Note Endpoint

The delete endpoint returns an empty response. HTMX replaces the deleted note card with nothing:

delete("/notes/{id}") {
    val id = call.parameters["id"]?.toIntOrNull() ?: return@delete
    noteRepository.delete(id)
    call.respondText("") // Empty response removes the element
}

HTMX Patterns

hx-get — Load Data

<div hx-get="/admin/notes" hx-trigger="load">Loading...</div>

hx-post — Submit Forms

<form hx-post="/admin/notes" hx-target="#notes-list" hx-swap="afterbegin">

hx-delete — Delete Items

<button hx-delete="/admin/notes/1" hx-target="#note-1" hx-swap="outerHTML">

hx-swap Options

ValueEffect
innerHTMLReplace the inner HTML of the target
outerHTMLReplace the entire target element
afterbeginInsert at the beginning of the target
beforeendInsert at the end of the target

hx-trigger Options

ValueEffect
clickOn click (default for buttons)
loadWhen element loads
submitOn form submit (default for forms)
every 5sPoll every 5 seconds

When to Use HTMX vs JavaScript Frontend

Use HTMX when:

  • Building admin dashboards
  • Internal tools
  • Simple forms and lists
  • You want to ship fast

Use a JavaScript frontend (React, Vue) when:

  • Complex client-side interactions (drag-and-drop, canvas)
  • Offline-first applications
  • Complex state management
  • Mobile apps (React Native)

Use a mobile app when:

  • You need native features (camera, push notifications)
  • Performance is critical
  • You are building for iOS/Android

Common Mistakes

  1. Returning JSON instead of HTML — HTMX expects HTML fragments.
  2. Not setting hx-target — Without a target, HTMX replaces the triggering element.
  3. Mixing HTMX with heavy JavaScript frameworks — Pick one approach.
  4. Not handling HTMX request headers — HTMX sends HX-Request: true header that you can use to distinguish HTMX requests from regular requests.

Source Code

You can find the source code for this tutorial on GitHub:

github.com/kemalcodes/ktor-tutorial — Branch: tutorial-17-htmx

What’s Next?

You have a server-rendered admin dashboard with HTMX. In the next tutorial, you will add Dependency Injection — structuring your application with Koin for better testability and maintainability.