Kotlinx.html #

Kotlinx.html is JetBrains’ official library that lets you create HTML using a Kotlin type-safe DSL (Domain Specific Language) — not string templates that are prone to typos and injection. Every HTML element is represented as a Kotlin function, every attribute as a compiler-checked property. You can’t write <div classe="container"> without the compiler immediately warning you, because classe isn’t a valid attribute. This makes kotlinx.html an attractive choice for generating HTML from Kotlin code — reports, emails, simple web pages, or UI components in server-side applications. This article covers all available HTML elements and attributes, how to build layouts and templates, Ktor integration, and when to use kotlinx.html versus template engines like Thymeleaf or Freemarker.

When to Use kotlinx.html #

CHOOSE kotlinx.html if:
  ✓ Generating HTML from Kotlin code with type safety
  ✓ HTML emails generated programmatically
  ✓ Dynamic HTML reports or documents
  ✓ Simple server-side rendering without a frontend framework
  ✓ Reusable UI components as Kotlin functions
  ✓ Testing HTML output from existing code

CHOOSE a template engine (Thymeleaf, Freemarker, Pebble) if:
  ✓ Non-programmer designers need to edit HTML templates
  ✓ Very complex HTML templates with lots of conditionals
  ✓ The team is more familiar with template engines
  ✓ You want to strictly separate HTML from business logic

Setup #

// build.gradle.kts
dependencies {
    // kotlinx.html for the JVM
    implementation("org.jetbrains.kotlinx:kotlinx-html-jvm:0.11.0")

    // For JavaScript (Kotlin/JS)
    // implementation("org.jetbrains.kotlinx:kotlinx-html-js:0.11.0")
}

Basics — Creating HTML #

import kotlinx.html.*
import kotlinx.html.stream.createHTML

fun main() {
    // createHTML() — generates an HTML string
    val html = createHTML().html {
        head {
            title("First Page")
            meta(charset = "UTF-8")
            meta(name = "viewport", content = "width=device-width, initial-scale=1.0")
        }
        body {
            h1 { +"Hello, Kotlinx.html!" }
            p { +"This is the first paragraph." }
            p {
                +"This is a paragraph with "
                strong { +"bold text" }
                +" and "
                em { +"italic text" }
                +"."
            }
        }
    }
    println(html)
}

Output:

<html>
  <head>
    <title>First Page</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
  </head>
  <body>
    <h1>Hello, Kotlinx.html!</h1>
    <p>This is the first paragraph.</p>
    <p>This is a paragraph with <strong>bold text</strong> and <em>italic text</em>.</p>
  </body>
</html>

Basic HTML Elements #

Headings and Paragraphs #

val html = createHTML().div {
    // Headings h1-h6
    h1 { +"Main Title (H1)" }
    h2 { +"Subtitle (H2)" }
    h3 { +"Sub Subtitle (H3)" }

    // Paragraphs and text
    p { +"A regular paragraph" }
    p {
        +"Text with "
        strong { +"bold" }
        +", "
        em { +"italic" }
        +", "
        u { +"underlined" }
        +", and "
        del { +"strikethrough" }
        +"."
    }

    // Quotes
    blockquote {
        +"This is a long quote that is usually indented."
        cite { +"— Author" }
    }

    // Inline and block code
    p { +"Use the "; code { +"println()" }; +" function for output." }
    pre {
        code {
            +"""
                fun main() {
                    println("Hello!")
                }
            """.trimIndent()
        }
    }

    // Line breaks and horizontal rules
    p { +"First line"; br; +"Second line after the break" }
    hr {}
}
val html = createHTML().div {
    // Links
    a(href = "https://kotlinlang.org") { +"Kotlin website" }
    a(href = "/halaman-lain", target = "_blank") { +"Open in a new tab" }

    // Link with CSS classes
    a(href = "#", classes = "btn btn-primary") { +"Link Button" }

    // Images
    img(src = "/gambar/foto.jpg", alt = "Product Photo")
    img {
        src = "https://cdn.example.com/logo.png"
        alt = "Logo"
        width = "200"
        height = "100"
        classes = "responsive-image"
    }

    // Image as a link
    a(href = "/produk/1") {
        img(src = "/gambar/produk-1.jpg", alt = "Gaming Laptop")
    }
}

Lists #

val html = createHTML().div {
    // Unordered list
    ul {
        li { +"First item" }
        li { +"Second item" }
        li {
            +"Third item with a sub-list"
            ul {
                li { +"Sub item A" }
                li { +"Sub item B" }
            }
        }
    }

    // Ordered list
    ol {
        li { +"First step" }
        li { +"Second step" }
        li { +"Third step" }
    }

    // Definition list
    dl {
        dt { +"Kotlin" }
        dd { +"A modern programming language for the JVM" }
        dt { +"Ktor" }
        dd { +"A Kotlin-native web framework from JetBrains" }
    }
}

Attributes, CSS, and Classes #

val html = createHTML().div {
    // id and class attributes
    div {
        id = "container"
        classes = "container mx-auto"

        p {
            id = "intro"
            classes = "text-lg text-gray-700"
            +"A paragraph with an ID and CSS classes."
        }
    }

    // Inline CSS
    div {
        style = "background-color: #f0f0f0; padding: 16px; border-radius: 8px;"
        +"A div with inline styles"
    }

    // data-* attributes
    div {
        attributes["data-id"] = "produk-123"
        attributes["data-kategori"] = "elektronik"
        attributes["aria-label"] = "Product list"
        +"A div with data attributes"
    }

    // Buttons with various attributes
    button(type = ButtonType.button) {
        id = "btn-submit"
        classes = "btn btn-primary"
        disabled = false
        onClick = "handleClick()"
        +"Click Me"
    }
}

Forms #

Forms are one of the most commonly needed HTML elements:

fun createRegistrationForm(): String = createHTML().form {
    action = "/daftar"
    method = FormMethod.post
    encType = FormEncType.multipartFormData  // for file uploads
    classes = "form-daftar"

    div(classes = "form-group") {
        label {
            htmlFor = "nama"
            +"Full Name"
        }
        textInput(name = "nama") {
            id = "nama"
            placeholder = "Enter your full name"
            required = true
            classes = "form-control"
        }
    }

    div(classes = "form-group") {
        label {
            htmlFor = "email"
            +"Email"
        }
        emailInput(name = "email") {
            id = "email"
            placeholder = "[email protected]"
            required = true
            classes = "form-control"
        }
    }

    div(classes = "form-group") {
        label {
            htmlFor = "sandi"
            +"Password"
        }
        passwordInput(name = "sandi") {
            id = "sandi"
            minLength = "8"
            required = true
            classes = "form-control"
        }
    }

    div(classes = "form-group") {
        label {
            htmlFor = "umur"
            +"Age"
        }
        numberInput(name = "umur") {
            id = "umur"
            min = "17"
            max = "100"
            classes = "form-control"
        }
    }

    div(classes = "form-group") {
        label {
            htmlFor = "kota"
            +"City"
        }
        select {
            id = "kota"
            name = "kota"
            classes = "form-select"
            option {
                value = ""
                +"-- Select City --"
            }
            listOf("Jakarta", "Bandung", "Surabaya", "Medan", "Makassar").forEach { city ->
                option {
                    value = city.lowercase()
                    +city
                }
            }
        }
    }

    div(classes = "form-group") {
        label { +"Gender" }
        div {
            label {
                radioInput(name = "jenis_kelamin") {
                    value = "L"
                }
                +" Male"
            }
            label {
                radioInput(name = "jenis_kelamin") {
                    value = "P"
                }
                +" Female"
            }
        }
    }

    div(classes = "form-group") {
        label {
            checkBoxInput(name = "setuju") {
                required = true
            }
            +" I agree to the terms and conditions"
        }
    }

    div(classes = "form-group") {
        label { +"Profile Photo" }
        fileInput(name = "foto") {
            accept = "image/*"
            classes = "form-control"
        }
    }

    div(classes = "form-group") {
        label {
            htmlFor = "bio"
            +"Bio"
        }
        textArea {
            id = "bio"
            name = "bio"
            rows = "4"
            placeholder = "Tell us about yourself..."
            classes = "form-control"
        }
    }

    submitInput {
        value = "Register Now"
        classes = "btn btn-primary btn-lg"
    }
}

Tables #

data class Produk(val id: Int, val nama: String, val harga: Double, val stok: Int)

fun createProductTable(products: List<Produk>): String = createHTML().div {
    classes = "table-responsive"

    table {
        classes = "table table-striped table-hover"

        thead {
            tr {
                th { +"#" }
                th { +"Product Name" }
                th { +"Price" }
                th { +"Stock" }
                th { +"Status" }
                th { +"Actions" }
            }
        }

        tbody {
            if (products.isEmpty()) {
                tr {
                    td {
                        colSpan = "6"
                        classes = "text-center"
                        +"No products"
                    }
                }
            } else {
                products.forEachIndexed { index, p ->
                    tr {
                        if (p.stok == 0) classes = "table-danger"
                        else if (p.stok < 10) classes = "table-warning"

                        td { +"${index + 1}" }
                        td { +p.nama }
                        td { +"Rp${"%,d".format(p.harga.toLong())}" }
                        td { +"${p.stok} units" }
                        td {
                            span {
                                classes = if (p.stok > 0) "badge bg-success" else "badge bg-danger"
                                +(if (p.stok > 0) "Available" else "Out of stock")
                            }
                        }
                        td {
                            a(href = "/produk/${p.id}", classes = "btn btn-sm btn-info me-1") {
                                +"Detail"
                            }
                            a(href = "/produk/${p.id}/edit", classes = "btn btn-sm btn-warning") {
                                +"Edit"
                            }
                        }
                    }
                }
            }
        }

        tfoot {
            tr {
                td {
                    colSpan = "3"
                    classes = "fw-bold"
                    +"Total: ${products.size} products"
                }
                td {
                    classes = "fw-bold"
                    +"${products.sumOf { it.stok }} units"
                }
                td {}
                td {}
            }
        }
    }
}

Templates and Layouts — Reusable Functions #

kotlinx.html’s power emerges when you create reusable components as Kotlin functions:

// A reusable base layout
fun FlowContent.layoutDasar(
    judul: String,
    cssExtra: String = "",
    konten: FlowContent.() -> Unit
) {
    val baseHtml = createHTML()
    // But it's more common to use an extension function on TagConsumer
}

// A more idiomatic pattern: create a function accepting a TagConsumer
fun TagConsumer<*>.halamanDasar(
    judul: String,
    konten: BODY.() -> Unit
) {
    html {
        head {
            meta(charset = "UTF-8")
            meta(name = "viewport", content = "width=device-width, initial-scale=1.0")
            title(judul)
            link(rel = "stylesheet", href = "https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css")
        }
        body {
            nav(classes = "navbar navbar-expand-lg navbar-dark bg-primary") {
                div(classes = "container") {
                    a(classes = "navbar-brand", href = "/") { +"MyApp" }
                }
            }

            main(classes = "container my-4") {
                konten()
            }

            footer(classes = "bg-light py-4 mt-auto") {
                div(classes = "container text-center text-muted") {
                    +"© 2024 MyApp. All rights reserved."
                }
            }

            script(src = "https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js") {}
        }
    }
}

// A reusable card component
fun FlowContent.card(
    judul: String,
    kelas: String = "",
    konten: FlowContent.() -> Unit
) {
    div(classes = "card $kelas") {
        div(classes = "card-header") {
            h5(classes = "card-title mb-0") { +judul }
        }
        div(classes = "card-body") {
            konten()
        }
    }
}

// An alert component
fun FlowContent.alert(pesan: String, tipe: String = "info") {
    div(classes = "alert alert-$tipe alert-dismissible fade show") {
        attributes["role"] = "alert"
        +pesan
        button(type = ButtonType.button, classes = "btn-close") {
            attributes["data-bs-dismiss"] = "alert"
            attributes["aria-label"] = "Close"
        }
    }
}

// Usage
fun createProductPage(products: List<Produk>, successMessage: String? = null): String {
    return createHTML().halamanDasar("Product List") {
        h1(classes = "mb-4") { +"Product List" }

        successMessage?.let {
            alert(it, "success")
        }

        card("All Products") {
            a(href = "/produk/tambah", classes = "btn btn-primary mb-3") {
                +"+ Add Product"
            }
            unsafe {
                +createProductTable(products)
            }
        }
    }
}

Ktor Integration #

import io.ktor.server.application.*
import io.ktor.server.html.*
import io.ktor.server.routing.*
import kotlinx.html.*

fun Route.htmlPageRoutes() {
    // Ktor provides call.respondHtml{} to generate HTML directly
    get("/produk") {
        val produk = getAllProducts()

        call.respondHtml {
            halamanDasar("Product List") {
                h1 { +"Product List" }
                unsafe { +createProductTable(produk) }
            }
        }
    }

    get("/produk/{id}") {
        val id = call.parameters["id"]?.toIntOrNull()
            ?: return@get call.respondHtml(io.ktor.http.HttpStatusCode.BadRequest) {
                body { p { +"Invalid ID" } }
            }

        val produk = getProductById(id)
            ?: return@get call.respondHtml(io.ktor.http.HttpStatusCode.NotFound) {
                halamanDasar("Not Found") {
                    div(classes = "alert alert-danger") {
                        +"Product with ID $id not found"
                    }
                    a(href = "/produk", classes = "btn btn-secondary") { +"← Back" }
                }
            }

        call.respondHtml {
            halamanDasar("Detail: ${produk.nama}") {
                h1 { +produk.nama }
                p { +"Price: Rp${"%,d".format(produk.harga.toLong())}" }
                p { +"Stock: ${produk.stok} units" }
            }
        }
    }

    get("/produk/tambah") {
        call.respondHtml {
            halamanDasar("Add Product") {
                h1 { +"Add a New Product" }
                unsafe { +createRegistrationForm() }
            }
        }
    }
}

// Placeholder functions
fun getAllProducts() = listOf(
    Produk(1, "Gaming Laptop", 15_000_000.0, 10),
    Produk(2, "Wireless Mouse", 250_000.0, 0)
)
fun getProductById(id: Int) = getAllProducts().find { it.id == id }

Generating HTML Emails #

One of kotlinx.html’s most common use cases is generating HTML emails:

fun createOrderConfirmationEmail(
    userName: String,
    orderId: String,
    items: List<Pair<String, Double>>,
    total: Double
): String = createHTML().html {
    head {
        meta(charset = "UTF-8")
        style {
            +"""
                body { font-family: Arial, sans-serif; margin: 0; padding: 0; background: #f4f4f4; }
                .container { max-width: 600px; margin: 20px auto; background: white; padding: 30px; border-radius: 8px; }
                .header { background: #2563eb; color: white; padding: 20px; border-radius: 8px 8px 0 0; text-align: center; }
                table { width: 100%; border-collapse: collapse; }
                th, td { padding: 12px; border-bottom: 1px solid #e5e7eb; text-align: left; }
                th { background: #f9fafb; }
                .total { font-size: 18px; font-weight: bold; color: #2563eb; }
                .footer { text-align: center; color: #6b7280; font-size: 12px; margin-top: 20px; }
            """.trimIndent()
        }
    }
    body {
        div(classes = "container") {
            div(classes = "header") {
                h2 { +"✅ Order Confirmed" }
                p { +"Thank you for your order!" }
            }

            div(classes = "content") {
                p { +"Hello, $userName!" }
                p { +"Your order with number #$orderId has been successfully confirmed." }

                h3 { +"Order Details" }
                table {
                    thead {
                        tr {
                            th { +"Product" }
                            th { +"Price" }
                        }
                    }
                    tbody {
                        items.forEach { (nama, harga) ->
                            tr {
                                td { +nama }
                                td { +"Rp${"%,d".format(harga.toLong())}" }
                            }
                        }
                    }
                    tfoot {
                        tr {
                            td { strong { +"Total" } }
                            td(classes = "total") {
                                +"Rp${"%,d".format(total.toLong())}"
                            }
                        }
                    }
                }

                p { +"Your order will be processed and shipped to your address soon." }

                div(classes = "footer") {
                    p { +"This email was sent automatically. Please do not reply to this email." }
                    p { +"© 2024 MyApp. Jl. Contoh No. 1, Jakarta" }
                }
            }
        }
    }
}

Unsafe — Inserting Raw HTML #

To insert pre-existing HTML into kotlinx.html:

val html = createHTML().div {
    // Insert raw HTML (not escaped)
    unsafe {
        +"""<p>This is <b>raw HTML</b> that is not escaped.</p>"""
        +createProductTable(listOf(Produk(1, "Laptop", 15_000_000.0, 5)))
    }

    // Compare with plain text which is escaped
    p {
        +"<script>alert('XSS')</script>"  // This is escaped: displayed as literal text
    }
}
Use unsafe { } carefully. HTML inserted through unsafe is not escaped, so it can be an XSS attack vector if the content comes from user input. Only use unsafe for HTML you fully control.

Testing HTML Output #

import org.junit.jupiter.api.Test
import kotlin.test.assertTrue
import kotlin.test.assertFalse

class HtmlTemplateTest {

    @Test
    fun `product table contains all product names`() {
        val products = listOf(
            Produk(1, "Gaming Laptop", 15_000_000.0, 10),
            Produk(2, "Wireless Mouse", 250_000.0, 0)
        )

        val html = createProductTable(products)

        assertTrue(html.contains("Gaming Laptop"))
        assertTrue(html.contains("Wireless Mouse"))
        assertTrue(html.contains("Rp15,000,000"))
    }

    @Test
    fun `out of stock products get the table-danger class`() {
        val products = listOf(Produk(1, "Mouse", 250_000.0, 0))
        val html = createProductTable(products)

        assertTrue(html.contains("table-danger"))
        assertTrue(html.contains("Out of stock"))
    }

    @Test
    fun `an empty table shows the no products message`() {
        val html = createProductTable(emptyList())
        assertTrue(html.contains("No products"))
    }

    @Test
    fun `the registration form contains the required fields`() {
        val form = createRegistrationForm()

        assertTrue(form.contains("""name="nama""""))
        assertTrue(form.contains("""name="email""""))
        assertTrue(form.contains("""name="sandi""""))
        assertTrue(form.contains("""type="submit""""))
    }

    @Test
    fun `XSS cannot pass through plain text`() {
        val htmlWithScript = createHTML().p {
            +"<script>alert('xss')</script>"
        }
        // Must be escaped
        assertFalse(htmlWithScript.contains("<script>"))
        assertTrue(htmlWithScript.contains("&lt;script&gt;"))
    }
}

Summary #

  • Type-safe HTML — kotlinx.html ensures you can’t write invalid attributes or elements. The compiler catches mistakes like misspelled attribute names before the application runs.
  • Components as functions — create reusable HTML elements as extension functions on FlowContent or TagConsumer. This is the idiomatic way to make “components” without a frontend framework.
  • + for text — use the + operator followed by a string to insert text. Text is automatically escaped from dangerous HTML characters like <, >, and &.
  • unsafe { } only for trusted HTML — the unsafe block doesn’t escape, so it can be an XSS hole if it receives user input. Always validate and sanitize before putting content in unsafe.
  • Ktor integration is seamlesscall.respondHtml { } in Ktor directly produces an HTML response with the correct content type. No extra configuration needed.
  • HTML emails are a perfect use case — for generating confirmation emails, notifications, or reports in HTML format, kotlinx.html is far safer and easier to maintain than string concatenation.
  • Test HTML output with contains() — simple tests checking for the presence of specific strings in the HTML output are enough for most cases. No complex HTML parsing needed.
  • Use classes = not class = — in Kotlin, class is a reserved keyword. kotlinx.html uses the classes property (with an s) instead.

← Previous: Vert.x   Next: Articles & Resources →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact