Let Ktor Write Your JSON: A Practical Look at ContentNegotiation
Ktor's ContentNegotiation plugin removes hand-rolled JSON serialization from your route handlers. Here's how to set it up with kotlinx.serialization, and the traps worth avoiding.
17 Sept 2026, 06:53 UTC

Every Ktor tutorial eventually hits the same awkward moment: your route handler has a Kotlin object, the client wants JSON, and you're tempted to hand-roll a string or call a serializer inline. It works for one route. By the tenth route, you have serialization logic scattered everywhere and no consistent content-type handling. Ktor's ContentNegotiation plugin exists precisely to delete that boilerplate — and once it's installed correctly, your handlers just return data classes.
The thesis is simple: install the plugin once, declare your models, and let Ktor handle the conversion in both directions. The trade-offs are real but manageable, and I'll show where they bite.
What ContentNegotiation actually does
ContentNegotiation is a Ktor server (and client) plugin that sits between your route handlers and the HTTP wire format. On the way out, it inspects the object your handler returns and serializes it to the content type the client asked for (via the Accept header). On the way in, it deserializes the request body into the type your handler declares with call.receive<T>(), based on the request's Content-Type header.
The practical effect: your routing code never touches a serializer directly. If the client sends Accept: application/json, a returned data class becomes JSON. If a client posts a JSON body to a route expecting a User, the plugin parses it — or rejects the request with a 415/400 before your handler runs.
Setting it up with kotlinx.serialization
This example assumes Ktor 2.x (the install(ContentNegotiation) syntax shown here is the 2.x API; 1.x differs). Add the dependencies in build.gradle.kts:
implementation("io.ktor:ktor-server-content-negotiation:2.3.0")
implementation("io.ktor:ktor-serialization-kotlinx-json:2.3.0")Keep the Ktor artifacts on the same version — mixing 2.3.x core with an older serialization artifact is a common source of runtime failures. Then install the plugin in your application module:
fun Application.module() {
install(ContentNegotiation) {
json(Json {
encodeDefaults = false
ignoreUnknownKeys = true
})
}
routing {
get("/users/{id}") {
val id = call.parameters["id"]!!.toInt()
call.respond(User(id, "Ada"))
}
post("/users") {
val user = call.receive<User>()
call.respond(HttpStatusCode.Created, user)
}
}
}
@Serializable
data class User(val id: Int, val name: String)Two configuration choices worth noting: encodeDefaults = false omits properties that still hold their default values, which keeps payloads small but means clients can't rely on every field being present. ignoreUnknownKeys = true makes deserialization tolerant of extra fields in incoming JSON — useful when clients evolve faster than your model.
Verify it by starting the server and running curl -H "Accept: application/json" http://localhost:8080/users/1 from any terminal. You should get {"id":1,"name":"Ada"} with a Content-Type: application/json response header. If you instead get a 406 or an empty body, the plugin either isn't installed or the serializer artifact is missing from the classpath.
Trade-offs and the traps that matter
Accidental data exposure. Everything in a @Serializable data class is serialized by default. If your model carries a password hash or internal flag, mark it @Transient (and give it a default value) or, better, don't expose your persistence model directly — map to a response DTO. This is the mistake most likely to hurt you in production.
Serialization cost. For typical request/response payloads the overhead is modest. kotlinx.serialization generates serializers at compile time, so it avoids the reflection cost you'd pay with default Jackson setups. If you're returning very large collections, measure before assuming the plugin is the bottleneck — and consider streaming or pagination rather than tuning the serializer.
Plugin ordering. If you also install Compression or CallLogging, install order affects behavior — for example, logging installed before negotiation may log raw objects rather than serialized output. If something looks off in logs or responses, check installation order first.
Jackson vs. kotlinx. If you need Jackson-specific annotations or polymorphic handling you already rely on, swap in ktor-serialization-jackson and call jackson { } instead of json { }. The route code doesn't change — that's the point of the abstraction.
Where to go from here
Start with the minimal setup above, then add one hardening step at a time: mark sensitive fields @Transient, decide on encodeDefaults based on your client contract, and write one integration test that posts JSON and asserts on the deserialized object. That single test catches serializer misconfiguration, version mismatches, and missing @Serializable annotations before they reach a deploy. ContentNegotiation won't make serialization decisions for you — but it makes sure you only have to make them once.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.