Skip to content

Kotlin Ktor — Server-Side Development Guide

DodaTech Updated 2026-06-28 8 min read

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

  1. Blocking the event loop: Ktor uses coroutines. Using Thread.sleep() or blocking I/O blocks the event loop. Use delay() and suspend functions.

  2. Not setting a request timeout: Default timeouts may be too long. Configure timeout in application.conf or programmatically.

  3. Leaking secrets in JWT configuration: Hardcoding JWT secrets in code is a security risk. Use environment variables or a secrets manager.

  4. Forgetting to handle CORS: Browsers block cross-origin requests without CORS headers. Install and configure the CORS plugin.

  5. Not using structured logging: Use a logging framework (Logback) with structured output for better Observability.

  6. Missing error handling in WebSocket connections: A WebSocket connection dropping without proper cleanup leaks resources. Always use try-catch-finally.

Practice Questions

  1. 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.

  1. 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.

  1. 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).

  1. 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.

  1. 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 is the difference between Ktor and Spring Boot?

Ktor is lighter, coroutine-native, and more modular. Spring Boot is heavier but has a larger ecosystem. Choose Ktor for microservices and coroutine-heavy apps.

Can Ktor serve static files?

Yes. Use the staticFiles() or singleFile() function in routing to serve HTML, CSS, JS, and images.

Does Ktor support OpenAPI documentation?

Yes. Use the ktor-openapi plugin or integrate with Swagger/OpenAPI generators.

Can I deploy Ktor to serverless platforms?

Yes. Ktor runs on AWS Lambda, Google Cloud Run, and other serverless platforms. Use the appropriate engine (Netty, CIOS, Jetty).

How do I configure Ktor for different environments?

Use HOCON configuration files (application.conf), environment variables, or programmatic configuration per environment.

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