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:

  1. HTTP method — GET, POST, PUT, DELETE, etc.
  2. Path — The URL pattern like /api/users/{id}
  3. 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

PatternExample URLCode
Static path/api/usersget("/api/users") { }
Path parameter/api/users/42get("/api/users/{id}") { }
Optional parameter/api/users or /api/users/42get("/api/users/{id?}") { }
Query parameter/api/users?name=Alexcall.queryParameters["name"]
Wildcard/api/files/anyget("/api/files/*") { }
Tailcard/api/files/a/b/cget("/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