Ktor #

Ktor is a Kotlin-native web framework created by JetBrains — the same company that created Kotlin. This makes it the most idiomatic framework for Kotlin: every API is designed to fully leverage Kotlin features — coroutines, DSLs, extension functions, and type safety. Ktor is highly modular: you only install the plugins you need (called features in older versions), keeping applications lightweight. There’s no magic annotation like in Spring Boot — everything is explicit and traceable. This article covers Ktor in depth: from setup and routing, important plugins, JWT authentication, the Ktor client, to testing and deployment.

Why Ktor #

CHOOSE Ktor if:
  ✓ Writing a new Kotlin-native microservice from scratch
  ✓ The team wants idiomatic and explicit code (no magic)
  ✓ You need coroutines as the primary primitive (async by default)
  ✓ You want full control over what's installed in the application
  ✓ You need the Ktor client for consuming external APIs
  ✓ WebSockets that integrate well with the server

CONSIDER something else if:
  ✗ A large team with a steep learning curve — Spring Boot is more familiar
  ✗ You need a very rich library ecosystem — Spring is broader
  ✗ You need native images right now — Quarkus is more mature for this

Project Setup #

// build.gradle.kts
plugins {
    kotlin("jvm") version "2.0.0"
    kotlin("plugin.serialization") version "2.0.0"
    id("io.ktor.plugin") version "2.3.10"
    application
}

val ktorVersion = "2.3.10"

dependencies {
    // Engine — choose one
    implementation("io.ktor:ktor-server-netty:$ktorVersion")        // production (more features)
    // implementation("io.ktor:ktor-server-cio:$ktorVersion")        // lightweight

    // Main plugins
    implementation("io.ktor:ktor-server-core:$ktorVersion")
    implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
    implementation("io.ktor:ktor-server-status-pages:$ktorVersion")
    implementation("io.ktor:ktor-server-call-logging:$ktorVersion")
    implementation("io.ktor:ktor-server-cors:$ktorVersion")
    implementation("io.ktor:ktor-server-auth:$ktorVersion")
    implementation("io.ktor:ktor-server-auth-jwt:$ktorVersion")
    implementation("io.ktor:ktor-server-request-validation:$ktorVersion")

    // Ktor client (for calling external APIs)
    implementation("io.ktor:ktor-client-core:$ktorVersion")
    implementation("io.ktor:ktor-client-cio:$ktorVersion")
    implementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")
    implementation("io.ktor:ktor-client-logging:$ktorVersion")

    // Database
    implementation("org.jetbrains.exposed:exposed-core:0.49.0")
    implementation("org.jetbrains.exposed:exposed-dao:0.49.0")
    implementation("org.jetbrains.exposed:exposed-jdbc:0.49.0")
    implementation("com.zaxxer:HikariCP:5.1.0")
    implementation("org.postgresql:postgresql:42.7.3")

    // Logging
    implementation("ch.qos.logback:logback-classic:1.5.3")

    // Testing
    testImplementation("io.ktor:ktor-server-test-host:$ktorVersion")
    testImplementation("org.jetbrains.kotlin:kotlin-test")
}

application {
    mainClass.set("com.myapp.MainKt")
}

Application Structure #

// src/main/kotlin/com/myapp/Main.kt
package com.myapp

import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import com.myapp.plugins.*

fun main() {
    embeddedServer(
        factory = Netty,
        port = System.getenv("PORT")?.toInt() ?: 8080,
        host = "0.0.0.0",
        module = Application::module
    ).start(wait = true)
}

fun Application.module() {
    // Install plugins in order
    konfigurasiSerialisasi()
    konfigurasiAuth()
    konfigurasiCors()
    konfigurasiValidasi()
    konfigurasiStatusPages()
    konfigurasiLogging()
    konfigurasiDatabase()
    konfigurasiRouting()
}

The Serialization Plugin #

// src/main/kotlin/com/myapp/plugins/Serialisasi.kt
import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.json.Json

fun Application.konfigurasiSerialisasi() {
    install(ContentNegotiation) {
        json(Json {
            prettyPrint = false
            isLenient = true
            ignoreUnknownKeys = true
            encodeDefaults = false  // don't send nulls that are defaults
        })
    }
}

Nested Routing #

// src/main/kotlin/com/myapp/plugins/Routing.kt
import io.ktor.server.application.*
import io.ktor.server.routing.*
import com.myapp.route.*

fun Application.konfigurasiRouting() {
    routing {
        // Health check — no auth needed
        get("/health") {
            call.respond(mapOf("status" to "ok", "waktu" to System.currentTimeMillis()))
        }

        // API version
        route("/api/v1") {
            produkRoutes()
            penggunaRoutes()
        }
    }
}
// src/main/kotlin/com/myapp/route/ProdukRoute.kt
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import kotlinx.serialization.Serializable
import org.jetbrains.exposed.sql.transactions.transaction

@Serializable
data class ProdukDto(
    val id: Long = 0,
    val nama: String,
    val harga: Double,
    val stok: Int = 0,
    val kategori: String? = null
)

@Serializable
data class ErrorResponse(val kode: Int, val pesan: String)

@Serializable
data class PaginatedResponse<T>(
    val data: List<T>,
    val total: Int,
    val halaman: Int,
    val ukuranHalaman: Int
)

fun Route.produkRoutes() {
    route("/produk") {

        get {
            val halaman = call.request.queryParameters["halaman"]?.toIntOrNull() ?: 1
            val ukuran = call.request.queryParameters["ukuran"]?.toIntOrNull() ?: 20
            val kategori = call.request.queryParameters["kategori"]

            require(halaman > 0) { "Halaman must be greater than 0" }
            require(ukuran in 1..100) { "Page size must be 1-100" }

            val produk = transaction { ambilSemuaProduk(kategori, halaman, ukuran) }
            call.respond(produk)
        }

        get("/{id}") {
            val id = call.parameters["id"]?.toLongOrNull()
                ?: return@get call.respond(
                    HttpStatusCode.BadRequest,
                    ErrorResponse(400, "ID must be a number")
                )

            val produk = transaction { ambilProdukById(id) }
                ?: return@get call.respond(
                    HttpStatusCode.NotFound,
                    ErrorResponse(404, "Produk $id not found")
                )

            call.respond(produk)
        }

        post {
            val dto = runCatching { call.receive<ProdukDto>() }.getOrElse {
                return@post call.respond(
                    HttpStatusCode.BadRequest,
                    ErrorResponse(400, "Invalid body: ${it.message}")
                )
            }

            val saved = transaction { simpanProduk(dto) }
            call.respond(HttpStatusCode.Created, saved)
        }

        put("/{id}") {
            val id = call.parameters["id"]?.toLongOrNull()
                ?: return@put call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, "Invalid ID"))

            val dto = call.receive<ProdukDto>()
            val updated = transaction { perbaruiProduk(id, dto) }
                ?: return@put call.respond(HttpStatusCode.NotFound, ErrorResponse(404, "Product not found"))

            call.respond(updated)
        }

        delete("/{id}") {
            val id = call.parameters["id"]?.toLongOrNull()
                ?: return@delete call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, "Invalid ID"))

            val success = transaction { hapusProduk(id) }
            if (success) call.respond(HttpStatusCode.NoContent)
            else call.respond(HttpStatusCode.NotFound, ErrorResponse(404, "Product not found"))
        }
    }
}

// Placeholder database functions
private fun ambilSemuaProduk(kategori: String?, halaman: Int, ukuran: Int): PaginatedResponse<ProdukDto> =
    PaginatedResponse(emptyList(), 0, halaman, ukuran)
private fun ambilProdukById(id: Long): ProdukDto? = null
private fun simpanProduk(dto: ProdukDto): ProdukDto = dto.copy(id = 1)
private fun perbaruiProduk(id: Long, dto: ProdukDto): ProdukDto? = dto.copy(id = id)
private fun hapusProduk(id: Long): Boolean = true

JWT Authentication #

// src/main/kotlin/com/myapp/plugins/Auth.kt
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import io.ktor.server.response.*
import io.ktor.http.*
import kotlinx.serialization.Serializable
import java.util.Date

object JwtConfig {
    val SECRET = System.getenv("JWT_SECRET") ?: "rahasia-development"
    val ISSUER = "myapp"
    val AUDIENCE = "myapp-users"
    val ALGORITHM = Algorithm.HMAC256(SECRET)
    val EXPIRY_MS = 24 * 60 * 60 * 1000L  // 24 hours
}

fun createToken(userId: Long, email: String): String {
    return JWT.create()
        .withIssuer(JwtConfig.ISSUER)
        .withAudience(JwtConfig.AUDIENCE)
        .withClaim("id", userId)
        .withClaim("email", email)
        .withExpiresAt(Date(System.currentTimeMillis() + JwtConfig.EXPIRY_MS))
        .sign(JwtConfig.ALGORITHM)
}

fun Application.konfigurasiAuth() {
    install(Authentication) {
        jwt("auth-jwt") {
            realm = "myapp"
            verifier(
                JWT.require(JwtConfig.ALGORITHM)
                    .withIssuer(JwtConfig.ISSUER)
                    .withAudience(JwtConfig.AUDIENCE)
                    .build()
            )
            validate { credential ->
                if (credential.payload.getClaim("email").asString().isNotEmpty()) {
                    JWTPrincipal(credential.payload)
                } else null
            }
            challenge { _, _ ->
                call.respond(HttpStatusCode.Unauthorized,
                    mapOf("error" to "Token is invalid or expired"))
            }
        }
    }
}

// Use in routes that need protection
fun Route.penggunaRoutes() {
    route("/auth") {
        post("/login") {
            @Serializable data class LoginRequest(val email: String, val sandi: String)
            @Serializable data class LoginResponse(val token: String, val penggunaId: Long)

            val req = call.receive<LoginRequest>()
            // Verify credentials (implemented in a service)
            val userId = 1L  // placeholder

            val token = createToken(userId, req.email)
            call.respond(LoginResponse(token, userId))
        }
    }

    // Routes protected by JWT
    authenticate("auth-jwt") {
        route("/profil") {
            get {
                val principal = call.principal<JWTPrincipal>()!!
                val email = principal.payload.getClaim("email").asString()
                val id = principal.payload.getClaim("id").asLong()
                call.respond(mapOf("id" to id, "email" to email))
            }
        }
    }
}

CORS and Status Pages Plugins #

import io.ktor.server.plugins.cors.routing.*
import io.ktor.server.plugins.statuspages.*
import io.ktor.http.*

fun Application.konfigurasiCors() {
    install(CORS) {
        allowMethod(HttpMethod.Options)
        allowMethod(HttpMethod.Get)
        allowMethod(HttpMethod.Post)
        allowMethod(HttpMethod.Put)
        allowMethod(HttpMethod.Delete)
        allowHeader(HttpHeaders.Authorization)
        allowHeader(HttpHeaders.ContentType)
        allowCredentials = true
        allowHost("localhost:3000")              // development
        allowHost("myapp.com", schemes = listOf("https"))  // production
    }
}

fun Application.konfigurasiStatusPages() {
    install(StatusPages) {
        // Handle exceptions centrally
        exception<IllegalArgumentException> { call, cause ->
            call.respond(HttpStatusCode.BadRequest, ErrorResponse(400, cause.message ?: "Invalid input"))
        }
        exception<NoSuchElementException> { call, cause ->
            call.respond(HttpStatusCode.NotFound, ErrorResponse(404, cause.message ?: "Not found"))
        }
        exception<Throwable> { call, cause ->
            application.log.error("Unhandled error", cause)
            call.respond(HttpStatusCode.InternalServerError, ErrorResponse(500, "Internal server error"))
        }

        // Handle HTTP status codes
        status(HttpStatusCode.NotFound) { call, _ ->
            call.respond(HttpStatusCode.NotFound, ErrorResponse(404, "Endpoint not found"))
        }
        status(HttpStatusCode.MethodNotAllowed) { call, _ ->
            call.respond(HttpStatusCode.MethodNotAllowed, ErrorResponse(405, "HTTP method not allowed"))
        }
    }
}

The Ktor Client — Calling External APIs #

Ktor isn’t just a server — it also has an excellent HTTP client, built on coroutines:

import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.engine.cio.*
import io.ktor.client.plugins.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.plugins.logging.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
import kotlinx.serialization.Serializable

@Serializable
data class ApiResponse<T>(val data: T, val status: String)

@Serializable
data class WeatherInfo(val city: String, val temperature: Double, val description: String)

// Create a reusable client (not created per request)
val httpClient = HttpClient(CIO) {
    install(ContentNegotiation) {
        json(Json { ignoreUnknownKeys = true })
    }
    install(Logging) {
        logger = Logger.DEFAULT
        level = LogLevel.INFO
    }
    install(HttpTimeout) {
        requestTimeoutMillis = 10_000  // 10 seconds
        connectTimeoutMillis = 5_000
        socketTimeoutMillis = 10_000
    }
    defaultRequest {
        header("Accept", "application/json")
        header("User-Agent", "MyKotlinApp/1.0")
    }
}

class ExternalWeatherService {

    suspend fun getWeather(city: String): WeatherInfo {
        val apiKey = System.getenv("WEATHER_API_KEY") ?: throw IllegalStateException("API key missing")

        return httpClient.get("https://api.cuaca.example.com/current") {
            parameter("q", city)
            parameter("appid", apiKey)
            parameter("units", "metric")
        }.body()
    }

    suspend fun sendWebhook(url: String, payload: Map<String, String>) {
        httpClient.post(url) {
            contentType(io.ktor.http.ContentType.Application.Json)
            setBody(payload)
        }
    }

    // POST with a typed JSON body
    suspend fun createOrder(payload: ProdukDto): ProdukDto {
        return httpClient.post("https://api.eksternal.com/pesanan") {
            contentType(io.ktor.http.ContentType.Application.Json)
            setBody(payload)
        }.body()
    }
}

Custom Plugins (Middleware) #

import io.ktor.server.application.*
import io.ktor.server.request.*
import io.ktor.util.*

// Custom plugin for request IDs
val RequestIdPlugin = createApplicationPlugin("RequestId") {
    onCall { call ->
        val requestId = call.request.header("X-Request-Id")
            ?: java.util.UUID.randomUUID().toString()
        call.response.header("X-Request-Id", requestId)
    }
}

// A simple rate limiting plugin (use a dedicated library for production)
val RateLimitPlugin = createApplicationPlugin("RateLimit") {
    val counts = java.util.concurrent.ConcurrentHashMap<String, java.util.concurrent.atomic.AtomicInteger>()

    onCall { call ->
        val ip = call.request.local.remoteAddress
        val count = counts.getOrPut(ip) { java.util.concurrent.atomic.AtomicInteger(0) }
        if (count.incrementAndGet() > 100) {
            call.respond(io.ktor.http.HttpStatusCode.TooManyRequests, "Rate limit exceeded")
            finish()
        }
    }
}

// Install in the application
fun Application.konfigurasiPlugin() {
    install(RequestIdPlugin)
    install(RateLimitPlugin)
}

Testing with testApplication #

import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlinx.serialization.json.Json
import kotlin.test.*

class ProdukRouteTest {

    @Test
    fun `GET health returns ok`() = testApplication {
        application { module() }

        val response = client.get("/health")
        assertEquals(HttpStatusCode.OK, response.status)
        assertTrue(response.bodyAsText().contains("ok"))
    }

    @Test
    fun `GET products returns an empty list`() = testApplication {
        application { module() }

        val response = client.get("/api/v1/produk")
        assertEquals(HttpStatusCode.OK, response.status)
    }

    @Test
    fun `POST a new product succeeds`() = testApplication {
        application { module() }

        val response = client.post("/api/v1/produk") {
            contentType(ContentType.Application.Json)
            setBody("""{"nama":"Laptop","harga":15000000.0,"stok":10}""")
        }
        assertEquals(HttpStatusCode.Created, response.status)
    }

    @Test
    fun `GET product with an invalid ID returns 400`() = testApplication {
        application { module() }

        val response = client.get("/api/v1/produk/bukan-angka")
        assertEquals(HttpStatusCode.BadRequest, response.status)
    }

    @Test
    fun `Non-existent endpoint returns 404`() = testApplication {
        application { module() }

        val response = client.get("/api/v1/tidak-ada")
        assertEquals(HttpStatusCode.NotFound, response.status)
    }

    @Test
    fun `POST with a valid JWT token succeeds`() = testApplication {
        application { module() }

        // Create a token for the test
        val token = createToken(1L, "[email protected]")

        val response = client.get("/api/v1/profil") {
            header("Authorization", "Bearer $token")
        }
        assertEquals(HttpStatusCode.OK, response.status)
    }
}

Deployment #

Docker #

# Dockerfile
FROM gradle:8.7-jdk17 AS build
WORKDIR /app
COPY . .
RUN gradle shadowJar --no-daemon

FROM openjdk:17-jre-slim
WORKDIR /app
COPY --from=build /app/build/libs/*-all.jar app.jar
EXPOSE 8080
ENV JAVA_OPTS="-Xms64m -Xmx256m"
CMD ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]
// build.gradle.kts — fat JAR configuration
plugins {
    id("com.github.johnrengelman.shadow") version "8.1.1"
}

tasks.shadowJar {
    manifest {
        attributes("Main-Class" to "com.myapp.MainKt")
    }
    mergeServiceFiles()
}

Configuration via Environment Variables #

// application.conf (HOCON)
ktor {
    deployment {
        port = 8080
        port = ${?PORT}  // override with the PORT env var
    }
    application {
        modules = [ com.myapp.MainKt.module ]
    }
}

database {
    url = ${?DATABASE_URL}
    user = ${?DATABASE_USER}
    password = ${?DATABASE_PASSWORD}
}

Summary #

  • Ktor is a Kotlin-first framework — every API is designed to leverage Kotlin features: coroutines for async, DSLs for routing and configuration, extension functions for readability. The result is very idiomatic code.
  • A modular plugin system — install only what you need: ContentNegotiation for JSON, Auth for authentication, CORS for cross-origin, StatusPages for centralized error handling. Nothing is installed without you knowing.
  • StatusPages for centralized error handling — handle all exceptions and HTTP status codes in one place rather than scattered across every handler. Cleaner and more consistent code.
  • Routing as extension functions — use fun Route.entityRoutes() to separate routes per entity. This makes routing easy to organize without annotations.
  • JWT with authenticate("auth-jwt") — protect routes with the authentication block. Use call.principal<JWTPrincipal>() to access token claims.
  • A coroutine-based Ktor client — use one HttpClient instance shared across the whole application. It’s thread-safe and supports all HTTP operations as suspend functions.
  • testApplication for in-memory testing — no need to open a network port for testing. testApplication runs the entire stack in-process, so tests are fast and deterministic.
  • Shadow JAR for deployment — build a fat JAR with all dependencies using the Shadow plugin, then deploy to Docker or any server with a JRE.

← Previous: Quarkus   Next: Vert.x →

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