Routing: Turning URLs Into Code


Routing is how Ktor decides which block of code answers a given request. It’s a small DSL, but it’s where you’ll spend a lot of your time, so let’s learn it properly. We’ll start building the task API here.

Verbs and paths

Inside routing { }, you declare handlers with the HTTP verb as the function name and the path as the argument:

routing {
    get("/tasks") {
        call.respondText("All tasks")
    }
    post("/tasks") {
        call.respondText("Created a task")
    }
    delete("/tasks") {
        call.respondText("Deleted everything")
    }
}

get, post, put, patch, delete — each maps a method-and-path pair to a handler. The same path can have different handlers per verb; GET /tasks and POST /tasks above are two separate routes. If a request matches no route, Ktor responds 404 automatically.

Path parameters

Most real URLs have variable parts: a task’s id, a user’s name. Mark them with braces in the path and read them from call.parameters:

get("/tasks/{id}") {
    val id = call.parameters["id"]
    call.respondText("You asked for task $id")
}

call.parameters["id"] returns a String?, null if the segment is somehow missing. You’ll usually want it as a number, so convert and handle the bad case:

get("/tasks/{id}") {
    val id = call.parameters["id"]?.toIntOrNull()
    if (id == null) {
        call.respondText("That's not a valid id", status = HttpStatusCode.BadRequest)
        return@get
    }
    call.respondText("Task #$id")
}

Two Kotlin idioms doing real work here: toIntOrNull() turns a bad id into null instead of throwing, and return@get bails out of the handler lambda early. (We’ll replace this hand-written validation with a cleaner pattern once we reach error handling — for now, see the shape of it.)

Query parameters

Query-string values — the ?status=done&limit=10 part — come from a different place: call.request.queryParameters.

get("/tasks") {
    val status = call.request.queryParameters["status"]   // "done", or null
    val limit = call.request.queryParameters["limit"]?.toIntOrNull() ?: 20
    call.respondText("Tasks with status=$status, limit=$limit")
}

Note the difference: path parameters are part of the route pattern (/tasks/{id}); query parameters are not declared in the path at all, so you just read whatever the client sent. The ?: 20 gives limit a sensible default when it’s absent.

Nesting routes

As an API grows, repeating /tasks on every line gets noisy. route(path) { } lets you nest: declare the common prefix once and group everything under it:

routing {
    route("/tasks") {
        get {                       // GET /tasks
            call.respondText("All tasks")
        }
        post {                      // POST /tasks
            call.respondText("Created a task")
        }
        route("/{id}") {
            get {                   // GET /tasks/{id}
                val id = call.parameters["id"]
                call.respondText("Task $id")
            }
            delete {                // DELETE /tasks/{id}
                val id = call.parameters["id"]
                call.respondText("Deleted task $id")
            }
        }
    }
}

Inside a route block, get { } and post { } take no path — they attach to the enclosing prefix. The nesting mirrors the URL hierarchy, so the structure of your routes reads like a map of your API.

Splitting routes into functions

Everything inside routing { } doesn’t have to live in one place. The receiver inside the block is a Route, so you can pull a group of routes into an extension function on Route and call it:

fun Route.taskRoutes() {
    route("/tasks") {
        get { call.respondText("All tasks") }
        post { call.respondText("Created a task") }
        get("/{id}") {
            call.respondText("Task ${call.parameters["id"]}")
        }
    }
}

Then your application module stays tidy, no matter how many resources you add:

fun Application.module() {
    routing {
        taskRoutes()
        // userRoutes()
        // healthRoutes()
    }
}

This is the single most useful organizing habit in Ktor: one extension function per resource. We’ll lean on it for the rest of the series, and revisit it properly when we structure the whole application.

A note on static files

Not everything is an API. If you need to serve a folder of static files — a built frontend, images, a robots.txt — Ktor has a one-liner:

routing {
    staticResources("/assets", "static")   // serves src/main/resources/static
}

Now GET /assets/logo.png returns resources/static/logo.png. Handy for small apps that ship a UI alongside the API.

Final thoughts

Routing in Ktor is just functions: a verb, a path, and a lambda, nested to match your URLs and split into Route extensions to stay readable. There’s no annotation scanner deciding what runs — the route tree is the code. Get comfortable here, because every feature for the rest of the series hangs off a route.

So far we’ve only sent back plain strings. Next: requests and responses — reading what the client sends and replying with the right status codes and headers.

Comments