Routing is the core of any backend framework. It maps URLs to code that handles requests and sends responses.
In this tutorial, you will learn how to define routes in Ktor, handle different HTTP methods, use path and query parameters, group routes, and handle errors properly.
How Routing Works in Ktor
Every Ktor route has three parts:
- HTTP method — GET, POST, PUT, DELETE, etc.
- Path — The URL pattern like
/api/users/{id} - Handler — The code that runs when a request matches
routing {
get("/hello") { // method + path
call.respondText("Hi") // handler
}
}
When a client sends GET /hello, Ktor finds the matching route and runs the handler.
Basic Routes
GET — Read Data
get("/api/users") {
call.respondText("List of users")
}
POST — Create Data
post("/api/users") {
call.respondText("User created", status = HttpStatusCode.Created)
}
PUT — Update Data
put("/api/users/{id}") {
val id = call.parameters["id"]
call.respondText("Updated user $id")
}
DELETE — Remove Data
delete("/api/users/{id}") {
val id = call.parameters["id"]
call.respondText("Deleted user $id")
}
Each HTTP method has a corresponding function in Ktor. The function takes a path and a handler lambda.
Path Parameters
Path parameters capture values from the URL. Use curly braces {name} to define them.
// URL: /api/users/42
get("/api/users/{id}") {
val id = call.parameters["id"] // "42"
call.respondText("User ID: $id")
}
Multiple Path Parameters
// URL: /api/users/42/notes/7
get("/api/users/{userId}/notes/{noteId}") {
val userId = call.parameters["userId"]
val noteId = call.parameters["noteId"]
call.respondText("User $userId, Note $noteId")
}
Optional Path Parameters
Add ? to make a parameter optional:
// Matches both /api/users and /api/users/42
get("/api/users/{id?}") {
val id = call.parameters["id"]
if (id != null) {
call.respondText("User $id")
} else {
call.respondText("All users")
}
}
Validating Path Parameters
Always validate path parameters. They come as strings, so you need to convert them:
get("/api/users/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respondText("Invalid ID", status = HttpStatusCode.BadRequest)
return@get
}
// Now id is a safe Int
val user = findUserById(id)
if (user == null) {
call.respondText("User not found", status = HttpStatusCode.NotFound)
return@get
}
call.respondText("Found: ${user.name}")
}
Use return@get to exit the handler early. This is a Kotlin feature for returning from a lambda.
Query Parameters
Query parameters are the ?key=value part of a URL. Access them with call.queryParameters:
// URL: /api/users?name=Alex&page=2
get("/api/users") {
val name = call.queryParameters["name"] // "Alex"
val page = call.queryParameters["page"] // "2"
call.respondText("Searching for $name, page $page")
}
Pagination Example
A common pattern is using query parameters for pagination:
get("/api/notes") {
val page = call.queryParameters["page"]?.toIntOrNull() ?: 1
val size = call.queryParameters["size"]?.toIntOrNull() ?: 10
val startIndex = (page - 1) * size
val pagedNotes = notes.drop(startIndex).take(size)
call.respondText(
pagedNotes.joinToString("\n") { "${it.id}: ${it.title}" }
)
}
If no page or size is provided, it defaults to page 1 with 10 items. The ?: operator (Elvis operator) makes this clean.
Filtering Example
Query parameters are great for filtering:
get("/api/users") {
val nameFilter = call.queryParameters["name"]
val result = if (nameFilter != null) {
users.filter { it.name.contains(nameFilter, ignoreCase = true) }
} else {
users
}
call.respondText(
result.joinToString("\n") { "${it.id}: ${it.name}" }
)
}
Route Grouping
As your API grows, you need to organize routes. Ktor uses the route() function for grouping.
Group by Path
routing {
route("/api") {
route("/users") {
get { /* GET /api/users */ }
post { /* POST /api/users */ }
get("/{id}") { /* GET /api/users/{id} */ }
put("/{id}") { /* PUT /api/users/{id} */ }
delete("/{id}") { /* DELETE /api/users/{id} */ }
}
route("/notes") {
get { /* GET /api/notes */ }
post { /* POST /api/notes */ }
}
}
}
Extract Routes to Functions
The best practice is to extract routes into extension functions on Route:
// routes/UserRoutes.kt
fun Route.userRoutes() {
route("/users") {
get { /* list users */ }
get("/{id}") { /* get user */ }
post { /* create user */ }
put("/{id}") { /* update user */ }
delete("/{id}") { /* delete user */ }
}
}
// routes/NoteRoutes.kt
fun Route.noteRoutes() {
route("/notes") {
get { /* list notes */ }
get("/{id}") { /* get note */ }
}
}
// plugins/Routing.kt
fun Application.configureRouting() {
routing {
get("/") { call.respondText("Hello, Ktor!") }
get("/health") { call.respondText("OK") }
route("/api") {
userRoutes()
noteRoutes()
}
}
}
This is the pattern we will use for the rest of this series. Each resource gets its own file. The main routing file just wires them together.
Request Body
For POST and PUT requests, you often need to read the request body. Without serialization (which we will add in the next tutorial), you can read raw text:
post("/api/users") {
val body = call.receiveText()
call.respondText("Received: $body", status = HttpStatusCode.Created)
}
Or use query parameters for simple cases:
post("/api/users") {
val name = call.queryParameters["name"]
val email = call.queryParameters["email"]
if (name.isNullOrBlank() || email.isNullOrBlank()) {
call.respondText(
"Name and email are required",
status = HttpStatusCode.BadRequest
)
return@post
}
// Create the user
val user = User(id = nextId(), name = name, email = email)
users.add(user)
call.respondText(
"Created: ${user.name}",
status = HttpStatusCode.Created
)
}
In the next tutorial, we will add JSON serialization so you can send and receive proper JSON objects.
Response Types
Ktor supports different response types:
Plain Text
call.respondText("Hello, World!")
HTML
call.respondText(
"<h1>Hello</h1>",
ContentType.Text.Html
)
Status Codes
call.respondText("Created", status = HttpStatusCode.Created) // 201
call.respondText("Bad Request", status = HttpStatusCode.BadRequest) // 400
call.respondText("Not Found", status = HttpStatusCode.NotFound) // 404
No Content
call.respond(HttpStatusCode.NoContent) // 204
Error Handling in Routes
There are two patterns for error handling in routes:
Pattern 1: Return Early
get("/api/users/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respondText("Invalid ID", status = HttpStatusCode.BadRequest)
return@get
}
val user = users.find { it.id == id }
if (user == null) {
call.respondText("User not found", status = HttpStatusCode.NotFound)
return@get
}
call.respondText("${user.name}")
}
Pattern 2: StatusPages Plugin
Install the StatusPages plugin and throw exceptions:
// Custom exception
class NotFoundException(message: String) : RuntimeException(message)
// In StatusPages plugin
install(StatusPages) {
exception<NotFoundException> { call, cause ->
call.respondText(cause.message ?: "Not found", status = HttpStatusCode.NotFound)
}
}
// In route handler
get("/api/users/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
?: throw BadRequestException("Invalid ID")
val user = users.find { it.id == id }
?: throw NotFoundException("User $id not found")
call.respondText("${user.name}")
}
Pattern 2 is cleaner for larger applications. We will use it more in later tutorials.
Complete Example
Here is the complete code for this tutorial. The project has three route files organized by resource.
UserRoutes.kt
package com.kemalcodes.routes
import io.ktor.http.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
data class User(val id: Int, val name: String, val email: String)
private val users = mutableListOf(
User(1, "Alex", "alex@example.com"),
User(2, "Sam", "sam@example.com"),
User(3, "Jordan", "jordan@example.com")
)
fun Route.userRoutes() {
route("/users") {
get {
val nameFilter = call.queryParameters["name"]
val result = if (nameFilter != null) {
users.filter { it.name.contains(nameFilter, ignoreCase = true) }
} else {
users
}
call.respondText(
result.joinToString("\n") { "${it.id}: ${it.name} (${it.email})" }
)
}
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respondText("Invalid ID", status = HttpStatusCode.BadRequest)
return@get
}
val user = users.find { it.id == id }
if (user == null) {
call.respondText("User not found", status = HttpStatusCode.NotFound)
return@get
}
call.respondText("${user.id}: ${user.name} (${user.email})")
}
post {
val name = call.queryParameters["name"]
val email = call.queryParameters["email"]
if (name.isNullOrBlank() || email.isNullOrBlank()) {
call.respondText("Name and email required", status = HttpStatusCode.BadRequest)
return@post
}
val newId = (users.maxOfOrNull { it.id } ?: 0) + 1
val user = User(newId, name, email)
users.add(user)
call.respondText(
"Created: ${user.id}: ${user.name}",
status = HttpStatusCode.Created
)
}
delete("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respondText("Invalid ID", status = HttpStatusCode.BadRequest)
return@delete
}
val removed = users.removeIf { it.id == id }
if (removed) {
call.respondText("Deleted user $id")
} else {
call.respondText("User not found", status = HttpStatusCode.NotFound)
}
}
}
}
NoteRoutes.kt
package com.kemalcodes.routes
import io.ktor.http.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
data class Note(val id: Int, val title: String, val content: String)
private val notes = mutableListOf(
Note(1, "First Note", "This is my first note"),
Note(2, "Shopping List", "Milk, eggs, bread"),
Note(3, "Ideas", "Learn Ktor, build an API")
)
fun Route.noteRoutes() {
route("/notes") {
get {
val page = call.queryParameters["page"]?.toIntOrNull() ?: 1
val size = call.queryParameters["size"]?.toIntOrNull() ?: 10
val startIndex = (page - 1) * size
val pagedNotes = notes.drop(startIndex).take(size)
call.respondText(
pagedNotes.joinToString("\n") { "${it.id}: ${it.title}" }
)
}
get("/{id}") {
val id = call.parameters["id"]?.toIntOrNull()
if (id == null) {
call.respondText("Invalid ID", status = HttpStatusCode.BadRequest)
return@get
}
val note = notes.find { it.id == id }
if (note == null) {
call.respondText("Note not found", status = HttpStatusCode.NotFound)
return@get
}
call.respondText("${note.id}: ${note.title}\n${note.content}")
}
}
}
Testing Routes
@Test
fun `list users returns all users`() = testApplication {
application { module() }
val response = client.get("/api/users")
assertEquals(HttpStatusCode.OK, response.status)
assertContains(response.bodyAsText(), "Alex")
}
@Test
fun `get user by id`() = testApplication {
application { module() }
val response = client.get("/api/users/1")
assertEquals(HttpStatusCode.OK, response.status)
assertContains(response.bodyAsText(), "Alex")
}
@Test
fun `filter users by name`() = testApplication {
application { module() }
val response = client.get("/api/users?name=Alex")
assertEquals(HttpStatusCode.OK, response.status)
assertContains(response.bodyAsText(), "Alex")
}
Run all tests:
./gradlew test
Routing Cheat Sheet
| Pattern | Example URL | Code |
|---|---|---|
| Static path | /api/users | get("/api/users") { } |
| Path parameter | /api/users/42 | get("/api/users/{id}") { } |
| Optional parameter | /api/users or /api/users/42 | get("/api/users/{id?}") { } |
| Query parameter | /api/users?name=Alex | call.queryParameters["name"] |
| Wildcard | /api/files/any | get("/api/files/*") { } |
| Tailcard | /api/files/a/b/c | get("/api/files/{...}") { } |
Source Code
You can find the source code for this tutorial on GitHub:
github.com/kemalcodes/ktor-tutorial — Branch: tutorial-04-routing
What’s Next?
In the next tutorial, we will add JSON serialization with kotlinx.serialization. You will learn how to send and receive JSON objects instead of plain text.
Ktor Tutorial #5: Serialization — JSON with kotlinx.serialization
Related Articles
- Ktor Tutorial #3: Project Setup — Application structure and configuration
- Ktor Tutorial #2: Ktor vs Spring Boot — Framework comparison
- Kotlin Tutorial: Complete Series — Learn Kotlin from scratch