Stop Hardcoding Ktor Routes: Type-Safe Endpoints with the Resources Plugin
Hardcoded string paths in Ktor routes hide renaming bugs until runtime. The Resources plugin turns endpoints into typed classes so the compiler catches mismatches — here's how to set it up and where it falls short.
18 Jan 2026, 15:50 UTC

Every Ktor codebase starts the same way: a handful of get("/articles/{id}") blocks with string paths. Then the API grows, someone renames {id} to {articleId} in one route but not the other, and the mismatch only surfaces when a client gets a 404 in production. String paths are invisible to the compiler, so the compiler cannot help you.
The Resources plugin fixes this by turning routes into typed classes. The thesis is simple: if a route is a class, renaming a parameter is a compile error everywhere it matters, not a runtime surprise.
What the Resources plugin actually does
Instead of embedding paths in strings, you annotate a serializable class with @Resource and let a Kotlin compiler plugin generate the mapping between class properties and URL segments. Ktor then routes incoming requests by matching them against those generated definitions.
You get three concrete wins:
- Typed path and query parameters. A property declared as
val id: Longarrives in your handler as aLong, not a string you parse and hope for the best. - Type-safe links. You can generate a URL from an instance of the resource class, so outbound links and redirects use the same definition as inbound routing.
- Refactoring safety. Renaming a property breaks compilation in every handler and link that references it, which is exactly what you want.
Setup: the plugin is not optional
This is the step people skip and then wonder why nothing compiles. Resources needs both the dependency and a Kotlin compiler plugin. In your Gradle Kotlin DSL build file (run from the project root, no special permissions needed):
plugins {
kotlin("jvm") version "2.1.0"
kotlin("plugin.serialization") version "2.1.0"
id("io.ktor.plugin") version "3.0.0"
}
dependencies {
implementation("io.ktor:ktor-server-resources:3.0.0")
implementation("io.ktor:ktor-server-netty:3.0.0")
}Two things to verify here. First, the ktor-server-resources version must match your core Ktor server version — mixing versions is a common source of strange serialization errors. Second, the serialization plugin is required because @Resource classes must be @Serializable. Check your exact versions against the Ktor release notes for your project; the versions above are illustrative, not a recommendation.
A worked example: an articles endpoint
Define the resource as a serializable class hierarchy:
import io.ktor.resources.Resource
import kotlinx.serialization.Serializable
@Resource("/articles")
class Articles {
@Resource("{id}")
class ById(val parent: Articles = Articles(), val id: Long)
}Then install the plugin and route against the classes, in your application module:
import io.ktor.server.application.*
import io.ktor.server.resources.*
import io.ktor.server.resources.get
import io.ktor.server.response.*
import io.ktor.server.routing.*
fun Application.configureRouting() {
install(Resources)
routing {
get { article ->
// article.id is already a Long
call.respondText("Article ${article.id}")
}
}
}A request to /articles/42 now lands in that handler with article.id == 42L. A request to /articles/abc fails type conversion before your code runs, so you never write the toLongOrNull() dance.
Generating a link uses the same definition:
val href = application.href(Articles.ById(id = 42))
// href == "/articles/42"To confirm the safety property yourself, rename id to articleId in ById and rebuild. The handler and the href call should fail to compile. If the build passes, the compiler plugin is not applied — that is your smoke test that the setup is real.
Where Resources falls short
Resources is a good default, not a universal one. The honest limitations:
- Wildcard-heavy or highly dynamic paths. If your routing logic depends on arbitrary segment counts or paths constructed at runtime from configuration, string-based routing is more flexible. Resources wants a static, declarable shape.
- Build complexity. You are adding a compiler plugin and a code generation step. Builds get marginally slower, and a missing plugin produces confusing compile errors rather than a clear "you forgot X" message.
- Coupling to kotlinx.serialization. Your resource classes live in that ecosystem, which matters if your project deliberately avoids it.
For a typical REST API with stable, hierarchical paths, none of these are dealbreakers. For a reverse-proxy-style service that forwards arbitrary paths, stay with strings.
Try it on one route first
Do not rewrite your whole routing table. Pick one resource — the one with the most path parameters and the most links pointing at it — convert it to a @Resource class, and rename a property to watch the compiler catch every reference. If that experience feels like a win, expand. If your routes turn out to be too dynamic for the declarative model, you have lost an afternoon, not a migration.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.