Stop Concatenating URLs: Ktor's Type-Safe Routing with the Resources Plugin
Ktor's Resources plugin turns URL routes into typed classes, converting runtime 404s and parsing bugs into compile errors. Here's how it works, where it pays off, and where plain strings still win.
09 Feb 2026, 20:04 UTC

If you've ever shipped a Ktor service and watched a 404 appear in production because someone typed "users/{id}" in one file and "user/{id}" in another, you know the failure mode of string-based routing: the compiler can't help you, and the mistake only surfaces at runtime. Ktor's Resources plugin replaces those strings with typed classes, so a misspelled path or a renamed parameter becomes a compile error instead of a broken endpoint.
The thesis is simple: define your routes as data, not strings. You get compile-time checking, automatic parameter mapping, and refactoring that actually works.
How the Resources plugin works
Instead of writing get("/articles/{id}"), you declare a class annotated with @Resource:
@Resource("/articles")
class Articles {
@Resource("{id}")
class Id(val parent: Articles = Articles(), val id: Long)
@Resource("search")
class Search(val parent: Articles = Articles(), val q: String, val page: Int = 1)
}Each class is one endpoint shape. Path segments come from the annotation, path parameters from constructor properties that match the {placeholder} names, and any extra properties become query parameters. In the example above, Search maps to /articles/search?q=ktor&page=2, with page defaulting to 1 when omitted.
Your routing block then handles typed instances instead of raw strings:
routing {
get<Articles.Id> { article ->
// article.id is already a Long — no parsing, no null checks
call.respond(repo.find(article.id))
}
get<Articles.Search> { search ->
call.respond(repo.search(search.q, search.page))
}
}The type parameter on get<T>() is the route. If a request arrives that doesn't match the shape — say, /articles/abc where id is a Long — Ktor rejects it before your handler runs.
Setup: two things people forget
The plugin doesn't ship in the core server artifact. In your Gradle build script (run from the project root, no special permissions needed):
dependencies {
implementation("io.ktor:ktor-server-resources:$ktor_version")
}Then install the plugin in your application module:
fun Application.module() {
install(Resources)
routing { /* typed routes here */ }
}The second gotcha: @Resource relies on Kotlin serialization. You need the kotlinx-serialization compiler plugin applied in your build script and the @Serializable annotation on your resource classes (the serialization plugin is what generates the machinery that maps properties to path and query slots). If your routes silently 404 after adding the plugin, a missing serialization setup is the first thing to check. Version note: this assumes Ktor 2.x/3.x with the matching kotlinx-serialization plugin version — check that your Ktor and serialization versions are compatible, since mismatches produce confusing build errors.
Where the payoff actually shows up: refactoring
The day-one benefit is catching typos. The month-six benefit is refactoring. With string routes, renaming /articles to /posts means a global search-and-replace across route definitions, client link builders, and tests — with no guarantee you found every occurrence.
With Resources, the route is a class. Rename a property and every handler that reads it fails to compile until fixed. Even better, you can generate links from the same types:
val href = application.href(Articles.Id(id = 42))
// produces "/articles/42"That means outbound links in emails, redirects, and API responses are built from the same definition as the inbound route. Change the path once, and both sides move together.
The trade-off: rigid schemas
Type-safe routing shines when your URL structure is predictable. It struggles when it isn't. If you serve user-generated slugs, wildcard paths (/files/**), or routes whose shape is decided at runtime from configuration, forcing them into @Resource classes adds ceremony without adding safety. Ktor doesn't make you choose globally — you can mix get<Articles.Id> for your structured API and plain get("/files/...") for the dynamic parts in the same application. Use Resources for the stable 80% and strings for the genuinely dynamic remainder.
There's also a readability cost for newcomers: a nested class hierarchy is less immediately scannable than a flat list of get("/path") calls. Keep resource classes grouped in one file so the API surface stays discoverable.
Verify it yourself
You can confirm the safety claim in about five minutes. Define a resource class with a Long path parameter, then deliberately rename the property in the class but not in the handler that reads it — the project should fail to compile. Then start the server and request the route with a non-numeric value (curl http://localhost:8080/articles/abc); you should get a client error rather than your handler throwing a NumberFormatException. If either check behaves differently, suspect the serialization plugin setup first.
String routing isn't wrong — for a three-endpoint service it's perfectly fine. But once your API grows past the point where you can hold every path in your head, letting the compiler proofread your URLs is one of the cheapest reliability wins Ktor offers.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.