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 {}
}
Links and Images #
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
}
}
Useunsafe { }carefully. HTML inserted throughunsafeis not escaped, so it can be an XSS attack vector if the content comes from user input. Only useunsafefor 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("<script>"))
}
}
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
FlowContentorTagConsumer. 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 — theunsafeblock doesn’t escape, so it can be an XSS hole if it receives user input. Always validate and sanitize before putting content inunsafe.- Ktor integration is seamless —
call.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 =notclass =— in Kotlin,classis a reserved keyword. kotlinx.html uses theclassesproperty (with an s) instead.