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
- The page loads with the form and an empty notes list
hx-trigger="load"sends GET/admin/notesimmediately- The response (HTML fragment) replaces the loading message
- When you submit the form,
hx-postsends a POST request - The response (new note card) is inserted at the top of the list
hx-on::after-requestresets 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
| Value | Effect |
|---|---|
innerHTML | Replace the inner HTML of the target |
outerHTML | Replace the entire target element |
afterbegin | Insert at the beginning of the target |
beforeend | Insert at the end of the target |
hx-trigger Options
| Value | Effect |
|---|---|
click | On click (default for buttons) |
load | When element loads |
submit | On form submit (default for forms) |
every 5s | Poll 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
- Returning JSON instead of HTML — HTMX expects HTML fragments.
- Not setting hx-target — Without a target, HTMX replaces the triggering element.
- Mixing HTMX with heavy JavaScript frameworks — Pick one approach.
- Not handling HTMX request headers — HTMX sends
HX-Request: trueheader 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.
Related Articles
- Ktor Tutorial #16: OpenAPI and Swagger — API documentation
- Ktor Tutorial #4: Routing — REST endpoints
- Ktor Tutorial #7: CRUD Operations — Repository pattern
- Kotlin Tutorial: Complete Series — Learn Kotlin from scratch