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. Usecall.principal<JWTPrincipal>()to access token claims.- A coroutine-based Ktor client — use one
HttpClientinstance shared across the whole application. It’s thread-safe and supports all HTTP operations as suspend functions.testApplicationfor in-memory testing — no need to open a network port for testing.testApplicationruns 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.