Type-Safe JSON Routes in Ktor with ContentNegotiation and kotlinx.serialization
Stop parsing JSON strings by hand in Ktor routes. Use ContentNegotiation with kotlinx.serialization for typed receive and respond calls.
06 Oct 2025, 23:28 UTC

The problem: parsing JSON by hand in every route
In a Ktor Server application, it is tempting to read the request body as a string, parse it with a JSON library, and map fields into a data class yourself. That works for one endpoint, but across many routes it becomes repetitive. Worse, renaming a field or changing its type compiles fine until a client sends the old payload and the server fails at runtime.
Ktor Server organizes cross-cutting behavior as installable plugins. In Ktor 2.0 and later, the older term "feature" was replaced by "plugin," and configuration happens by calling install inside the application module. For JSON APIs, the practical choice is pairing ContentNegotiation with kotlinx.serialization.
How the plugin handles bodies and content types
When you install ContentNegotiation and register a JSON converter, Ktor inspects the Content-Type header on incoming requests. If the header matches a registered converter, Ktor deserializes the body into the Kotlin type you request. On the response side, it serializes return objects and sets the response content type automatically.
Your route code can then call receive() and respond(User) instead of parsing text. The wire contract becomes explicit: clients must send application/json, and the payload must match the serializable class. If they do not, Ktor rejects the request before your route logic runs.
Version note: the examples below use the Ktor 2.x/3.x plugin API. Older tutorials that call Application.feature or configure converters outside install will not compile on current Ktor lines. Check your resolved dependency version before pasting snippets.
Worked example: typed user routes
Run these commands in your project root. No elevated permissions are required.
- Add the serialization compiler plugin and Ktor JSON artifacts. Replace
your-kotlin-versionwith your Kotlin toolchain version andyour-ktor-versionwith the resolved Ktor 2.x or 3.x version from your build output.
plugins {
id("org.jetbrains.kotlin.plugin.serialization") version "your-kotlin-version"
}
dependencies {
val ktorVersion = "your-ktor-version"
implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
implementation("io.ktor:ktor-server-core:$ktorVersion")
implementation("io.ktor:ktor-server-netty:$ktorVersion")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
}
The Kotlin serialization compiler plugin is required. Without it, a missing serializer can surface as a runtime error rather than a clear compile error in some setups.
Next, mark the domain type and configure the plugin in your application module:
import io.ktor.application.Application
import io.ktor.application.install
import io.ktor.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.serialization.Serializable
@Serializable
data class User(val id: Int, val name: String)
fun Application.module() {
install(ContentNegotiation) {
json()
}
routing {
get("/users/{id}") {
val id = call.parameters["id"]?.toIntOrNull() ?: error("invalid id")
call.respond(User(id, "Example"))
}
post("/users") {
val user: User = call.receive()
call.respond(user)
}
}
}
Diagnostic checks with curl
Start the server locally, then run these curl commands from a terminal. These describe expected behavior; actual status lines depend on your server configuration and Ktor version.
- Send a valid request with the correct header:
curl -i -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"id": 1, "name": "Ada"}'
Expected check: the response should include Content-Type: application/json and echo the object. A 415 status means the header was missing or mismatched.
- Send a body without the JSON header:
curl -i -X POST http://localhost:8080/users \
-H "Content-Type: text/plain" \
-d '{"id": 1, "name": "Ada"}'
Expected check: Ktor should reject the request before reaching the route, commonly observed as a 415 Unsupported Media Type response.
- Send malformed JSON:
curl -i -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{bad json'
Expected check: a 400-class response from the deserialization failure. You can customize this shape later by installing StatusPages.
Trade-offs and limitations
Plugin-based serialization creates an implicit wire contract. Clients must send the expected content type and schema. Unknown fields are handled according to your Json configuration: ignoreUnknownKeys = true drops them, while the default behavior rejects them. Changing options such as naming strategy or explicitNulls can silently alter what clients must send, so treat those settings as part of your API contract.
Content negotiation only converts bodies when the content type matches. A valid JSON payload sent as text/plain is still rejected. For streaming, multipart uploads, or proxying opaque payloads, manual body handling remains the better fit.
Practical check: temporarily disable the serialization compiler plugin and compile a route that receives a serializable type. Serializer-not-found failures confirm the plugin is doing the work; re-enable it before deploying.
Closing decision and rollback
Prefer ContentNegotiation with kotlinx.serialization for internal JSON APIs where Kotlin data classes are the source of truth. Typed receive and respond remove repetitive parsing and make schema changes compile-time visible. Keep manual body handling for opaque or streaming payloads.
To roll back, remove the install(ContentNegotiation) block, delete the serialization dependencies, and replace typed calls with call.receiveText() plus manual parsing. Revert the serialization compiler plugin if no other code uses it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.