Kotlin Ktor — Server-Side Development Guide
In this tutorial, you will learn about Kotlin Ktor. We cover key concepts, practical examples, and best practices to help you master this topic.
Ktor is a Kotlin-native framework for building server-side applications and HTTP clients with a modular plugin system, coroutine-based async processing, routing DSL, content negotiation, authentication, and WebSocket support.
What You'll Learn
- Set up a Ktor server project
- Define routes with the routing DSL
- Handle request parameters and bodies
- Serialize and deserialize JSON with kotlinx.serialization
- Add authentication with JWT or sessions
- Implement WebSocket endpoints
- Use Ktor client for HTTP calls
- Test Ktor applications
Why It Matters
Ktor lets you use Kotlin on the server with the same language features you use in Android development: coroutines, Flow, serialization, and structured concurrency. It is lightweight, modular, and does not force an opinionated framework structure. Ktor is ideal for microservices, API backends, and real-time applications.
Real-World Use
DodaTech uses Ktor for internal API services that process malware signatures. The server receives scan submissions via POST endpoints, validates them with Ktor's content negotiation, stores results in a database, and broadcasts real-time scan progress via WebSockets.
Learning Path
flowchart LR A[KMP Basics] --> B[Ktor\nYou are here] B --> C[Testing] style B fill:#f90,color:#fff
Project Setup
// build.gradle.kts
plugins {
kotlin("jvm") version "2.0.21"
kotlin("plugin.serialization") version "2.0.21"
}
dependencies {
implementation("io.ktor:ktor-server-core:2.3.12")
implementation("io.ktor:ktor-server-netty:2.3.12")
implementation("io.ktor:ktor-server-content-negotiation:2.3.12")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.12")
implementation("io.ktor:ktor-server-auth:2.3.12")
implementation("io.ktor:ktor-server-auth-jwt:2.3.12")
implementation("io.ktor:ktor-server-websockets:2.3.12")
implementation("ch.qos.logback:logback-classic:1.5.6")
implementation("org.jetbrains.exposed:exposed-core:0.52.0")
testImplementation("io.ktor:ktor-server-test-host:2.3.12")
testImplementation(kotlin("test"))
}
Basic Server
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun main() {
embeddedServer(Netty, port = 8080) {
configureRouting()
}.start(wait = true)
}
fun Application.configureRouting() {
routing {
get("/") {
call.respondText("Hello, Ktor!")
}
get("/health") {
call.respond(mapOf("status" to "ok", "timestamp" to System.currentTimeMillis()))
}
}
}
Output: Running the server, visit http://localhost:8080/ returns "Hello, Ktor!" and /health returns JSON.
Routing and Parameters
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
fun Application.main() {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
prettyPrint = true
})
}
routing {
// Path parameters
get("/users/{id}") {
val userId = call.parameters["id"] ?: return@get
call.respond(User(userId.toIntOrNull() ?: 0, "User $userId"))
}
// Query parameters
get("/search") {
val query = call.request.queryParameters["q"] ?: ""
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
call.respond(mapOf("query" to query, "page" to page))
}
// POST with JSON body
post("/users") {
val user = call.receive<User>()
println("Created user: ${user.name}")
call.respond(HttpStatusCode.Created, user)
}
}
}
@Serializable
data class User(val id: Int, val name: String, val email: String = "")
Output: GET /users/42 returns user JSON. POST /users with a JSON body deserializes the body and responds.
Content Negotiation
Ktor automatically serializes and deserializes based on Content-Type headers.
fun Application.configureSerialization() {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true
isLenient = true
prettyPrint = false
})
}
}
// Now any route can return any serializable object
@Serializable
data class ApiResponse(
val success: Boolean,
val message: String,
val data: List<String>? = null
)
routing {
get("/api/items") {
call.respond(ApiResponse(
success = true,
message = "Items retrieved",
data = listOf("item1", "item2", "item3")
))
}
}
Output: The response is automatically serialized to JSON with the correct Content-Type header.
Authentication
Add JWT authentication to protect routes.
import io.ktor.server.auth.*
import io.ktor.server.auth.jwt.*
import com.auth0.jwk.JwkProvider
import com.auth0.jwt.JWT
import com.auth0.jwt.algorithms.Algorithm
fun Application.configureAuth() {
val secret = System.getenv("JWT_SECRET") ?: "default-secret-change-in-production"
install(Authentication) {
jwt("auth-jwt") {
verifier(JWT.require(Algorithm.HMAC256(secret))
.withIssuer("my-app")
.build()
)
validate { credential ->
if (credential.payload.getClaim("userId").asLong() != null) {
JWTPrincipal(credential.payload)
} else null
}
}
}
routing {
authenticate("auth-jwt") {
get("/protected") {
val principal = call.principal<JWTPrincipal>()
val userId = principal?.payload?.getClaim("userId")?.asLong()
call.respond(mapOf("message" to "Hello user $userId"))
}
}
// Public route
get("/public") {
call.respond(mapOf("message" to "Public endpoint"))
}
}
}
Output: GET /protected with a valid JWT returns user data. Without a token, it returns 401 Unauthorized.
WebSockets
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import java.time.Duration
fun Application.configureWebSockets() {
install(WebSockets) {
pingPeriod = Duration.ofSeconds(15)
timeout = Duration.ofSeconds(15)
maxFrameSize = Long.MAX_VALUE
masking = false
}
routing {
webSocket("/chat") {
println("Client connected: $this")
try {
for (frame in incoming) {
if (frame is Frame.Text) {
val receivedText = frame.readText()
println("Received: $receivedText")
send(Frame.Text("Echo: $receivedText"))
}
}
} catch (e: Exception) {
println("WebSocket error: ${e.message}")
} finally {
println("Client disconnected")
}
}
}
}
Output: Connect to ws://localhost:8080/chat. Send a message, receive the echo response.
Ktor Client
Ktor provides a multiplatform HTTP client.
import io.ktor.client.*
import io.ktor.client.call.*
import io.ktor.client.plugins.contentnegotiation.*
import io.ktor.client.request.*
import io.ktor.serialization.kotlinx.json.*
class ApiClient(private val baseUrl: String) {
private val client = HttpClient {
install(ContentNegotiation) {
json(Json { ignoreUnknownKeys = true })
}
}
suspend fun getUsers(): List<User> {
return client.get("$baseUrl/users").body()
}
suspend fun createUser(name: String, email: String): User {
return client.post("$baseUrl/users") {
setBody(User(0, name, email))
}.body()
}
suspend fun deleteUser(id: Int): Boolean {
val response = client.delete("$baseUrl/users/$id")
return response.status.isSuccess()
}
fun close() {
client.close()
}
}
Testing Ktor Applications
Use the test host for integration tests.
import io.ktor.client.request.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.server.testing.*
import kotlin.test.*
class ApplicationTest {
@Test
fun testRootEndpoint() = testApplication {
application {
module()
}
val response = client.get("/")
assertEquals(HttpStatusCode.OK, response.status)
assertEquals("Hello, Ktor!", response.bodyAsText())
}
@Test
fun testHealthEndpoint() = testApplication {
application {
module()
}
val response = client.get("/health")
assertEquals(HttpStatusCode.OK, response.status)
assertTrue(response.bodyAsText().contains("status"))
}
@Test
fun testCreateUser() = testApplication {
application {
module()
}
val response = client.post("/users") {
contentType(ContentType.Application.Json)
setBody("""{"id": 0, "name": "Test", "email": "test@test.com"}""")
}
assertEquals(HttpStatusCode.Created, response.status)
}
}
Database Integration with Exposed
Ktor pairs well with the Exposed SQL framework.
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
import org.jetbrains.exposed.sql.SqlExpressionBuilder.eq
object Users : Table("users") {
val id = integer("id").autoIncrement()
val name = varchar("name", 255)
val email = varchar("email", 255)
override val primaryKey = PrimaryKey(id)
}
class UserRepository(private val database: Database) {
fun getAllUsers(): List<UserRow> = transaction(database) {
Users.selectAll().map { it.toUserRow() }
}
fun getUserById(id: Int): UserRow? = transaction(database) {
Users.selectAll().where { Users.id eq id }
.singleOrNull()?.toUserRow()
}
fun createUser(name: String, email: String): UserRow = transaction(database) {
val id = Users.insert {
it[Users.name] = name
it[Users.email] = email
} get Users.id
getUserById(id)!!
}
}
data class UserRow(val id: Int, val name: String, val email: String)
fun ResultRow.toUserRow() = UserRow(
id = this[Users.id],
name = this[Users.name],
email = this[Users.email]
)
Common Mistakes
Blocking the event loop: Ktor uses coroutines. Using Thread.sleep() or blocking I/O blocks the event loop. Use delay() and suspend functions.
Not setting a request timeout: Default timeouts may be too long. Configure timeout in application.conf or programmatically.
Leaking secrets in JWT configuration: Hardcoding JWT secrets in code is a security risk. Use environment variables or a secrets manager.
Forgetting to handle CORS: Browsers block cross-origin requests without CORS headers. Install and configure the CORS plugin.
Not using structured logging: Use a logging framework (Logback) with structured output for better Observability.
Missing error handling in WebSocket connections: A WebSocket connection dropping without proper cleanup leaks resources. Always use try-catch-finally.
Practice Questions
- How does Ktor handle JSON serialization?
Answer: Install the ContentNegotiation plugin with the JSON serializer. Routes return serializable objects directly. Ktor handles serialization based on Content-Type.
- How do you protect routes with authentication?
Answer: Install the Authentication plugin with a JWT provider. Mark routes with authenticate("auth-name"). The validator checks token validity.
- What is the difference between a route parameter and a query parameter?
Answer: Route parameters are part of the URL path (/users/{id}). Query parameters are in the URL query string (?page=1&q=search).
- How do you test a Ktor application?
Answer: Use testApplication { } from ktor-server-test-host. It starts a test server and provides a test client for HTTP calls.
- Challenge: Build a REST API for a todo list with Ktor. Support CRUD operations, search by status, pagination, and JWT authentication. Use Exposed for database access.
Answer:
@Serializable
data class TodoItem(
val id: Int = 0,
val title: String,
val description: String = "",
val isCompleted: Boolean = false,
val userId: Long
)
@Serializable
data class CreateTodoRequest(val title: String, val description: String = "")
@Serializable
data class PaginatedResponse<T>(
val items: List<T>,
val page: Int,
val total: Int
)
fun Application.todoRoutes() {
val repository = TodoRepository()
routing {
authenticate("auth-jwt") {
get("/todos") {
val userId = call.principal<JWTPrincipal>()?.payload?.getClaim("userId")?.asLong()
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1
val completed = call.request.queryParameters["completed"]?.toBoolean()
val todos = repository.getTodos(userId!!, page, completed)
call.respond(PaginatedResponse(todos, page, repository.count()))
}
post("/todos") {
val userId = call.principal<JWTPrincipal>()?.payload?.getClaim("userId")?.asLong()
val request = call.receive<CreateTodoRequest>()
val todo = repository.createTodo(request.title, request.description, userId!!)
call.respond(HttpStatusCode.Created, todo)
}
delete("/todos/{id}") {
val id = call.parameters["id"]?.toIntOrNull() ?: throw IllegalArgumentException()
repository.deleteTodo(id)
call.respond(HttpStatusCode.NoContent)
}
}
}
}
Mini Project
Build a URL shortener API with Ktor. Requirements:
- POST /shorten accepts a long URL, returns a short code
- GET /{code} redirects to the original URL
- GET /stats/{code} returns click count and timestamps
- In-memory storage (later upgrade to Exposed)
- JSON request/response with ContentNegotiation
- Rate Limiting plugin
- Test all endpoints with test host
This project consolidates all Ktor concepts in a practical, deployable application.
FAQ
What's Next
After mastering Ktor, learn testing with kotlin.test. You can also explore KMP basics for cross-platform development.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro