Building a Minimal JSON API with Ktor’s ContentNegotiation and kotlinx.serialization
Learn how to wire Ktor’s ContentNegotiation plugin with kotlinx.serialization to accept and return Kotlin data classes, see a working route, run an in‑process test, and understand the trade‑offs versus heavier frameworks.
29 Jun 2026, 23:39 UTC

Problem: You need a lightweight server that can deserialize JSON into Kotlin objects and serialize responses without pulling in a full‑stack framework.
Many teams reach for Spring Boot or Micronaut when they want a JSON API, but those bring opinionated DI containers, starters, and build‑time code generation that may be unnecessary for a small service. Ktor’s plugin‑based architecture lets you add only the features you need, keeping the runtime footprint low while still offering idiomatic Kotlin APIs.
Thesis: By installing the ContentNegotiation plugin with kotlinx.serialization, you can write handlers that directly return or receive Kotlin data classes, centralize error handling with StatusPages, and verify behavior with an in‑process testApplication block.
Setting up the project
Start from the Ktor project generator (start.ktor.io) and select:
- Ktor version:
2.3.0(adjust if you use a newer 2.x release) - Server engine: Netty (or Jetty – the code works the same)
- Features: ContentNegotiation, Routing, StatusPages
- Serialization: kotlinx.serialization JSON
The generated build.gradle.kts will contain:
dependencies {
implementation("io.ktor:ktor-server-netty:$ktor_version")
implementation("io.ktor:ktor-server-content-negotiation:$ktor_version")
implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
testImplementation("io.ktor:ktor-server-tests:$ktor_version")
}
Make sure the Kotlin compiler plugin for serialization is applied:
plugins {
kotlin("jvm") version "1.9.0"
kotlin("plugin.serialization") version "1.9.0"
}
Installing ContentNegotiation
In Application.kt (or wherever you configure the engine), install the plugin once:
fun Application.module() {
install(ContentNegotiation) {
json(Json {
ignoreUnknownKeys = true // lenient for evolving clients
prettyPrint = false // compact payloads in prod
})
}
install(Routing) {
// routes go here
}
install(StatusPages) {
// centralized error handling – see below
}
}
This block tells Ktor to add a serialization step to the pipeline for any request/response where the Content-Type or Accept header signals JSON. No extra code is needed in each route.
Defining a data class and a route
Assume a simple resource representing a user:
@Serializable
data class User(val id: Long, val name: String, val email: String)
The @Serializable annotation is required; omitting it leads to a compile‑time error like “No serializer found for class User”.
Now a POST route that creates a user (in a real app you’d persist it):
post("/users") {
val incoming = call.receive() // deserializes JSON body
val created = User(id = System.currentTimeMillis(), name = incoming.name, email = incoming.email)
call.respond(status = HttpStatus.Created, created) // serialized automatically
}
The handler does three things:
- Receives the body via
call.receive()– Ktor uses the installed JSON serializer. - Creates a response object with a generated ID.
- Responds with HTTP 201 and the serialized
Userinstance.
If the client omits Content-Type: application/json, Ktor will fall back to its default (plain text) and the deserialization will fail with a 400 Bad Request. Similarly, missing Accept leads to a 406 Not Acceptable unless you configure a fallback.
Centralizing error handling with StatusPages
Instead of scattering try/catch blocks, map exceptions to JSON error payloads:
class NotFoundException(val msg: String) : Exception(msg)
install(StatusPages) {
exception<NotFoundException> { cause ->
call.respond(HttpStatus.NotFound, ErrorResponse(cause.msg))
}
exception<Exception> { cause ->
call.respond(HttpStatus.InternalServerError, ErrorResponse("Unexpected error"))
}
}
@Serializable
data class ErrorResponse(val message: String)
Anywhere in your route you can throw NotFoundException("User not found") and the client receives a consistent JSON body.
Verifying behavior with testApplication
Ktor’s testApplication lets you exercise the full pipeline without opening a network port. Add a test in src/test/kotlin:
class UserApiTest {
@Test
fun `POST /users returns created user`() = testApplication {
application { // re‑use the same module() configuration
module()
}
val payload = """{"name":"Ada","email":"[contact removed]"}"""
val response = post("/users") {
contentType(ContentType.Application.Json)
setBody(payload)
}
// status check
assertEquals(HttpStatus.Created, response.status)
// body check – deserialize back to User for assertion
val user = response.deserializeBody()
assertEquals("Ada", user.name)
assertEquals("[contact removed]", user.email)
assertTrue(user.id > 0)
}
}
Run the test with Gradle:
./gradlew test # requires JDK 17+ for Ktor 2.3
The test executes in‑process; no port is bound, so it’s safe to run on CI agents without extra container setup.
Trade‑offs and limitations
Ktor gives you freedom but leaves several concerns to the developer:
- No built‑in DI. You can add
ktor-server-dicontaineror a third‑party framework (Koin, Dagger) if you need lifecycle management. - Validation is manual. Unlike Spring’s
@Valid, you must invoke a validation library (e.g., Kotlinx‑validation) yourself before using the received object. - OpenAPI generation. Ktor does not produce Swagger/OpenAPI docs out of the box; you need a plugin like
ktor-server-openapior write them manually. - Version stability. The move from Ktor 2.x to 3.x changed package names (e.g.,
io.ktor.server.*) and some plugin signatures. Pin the version ingradle.propertiesand consult the migration guide when upgrading.
To verify that your setup works as described, after running the test you can also execute a manual curl command:
curl -i -X POST http://localhost:8080/users \
-H "Content-Type: application/json" \
-d '{"name":"Bob","email":"[contact removed]"}'
You should see a 201 response with a JSON body containing the echoed name and email plus an generated id. If you receive 400 or 406, double‑check that the headers are set correctly.
Actionable closing
Start with the minimal plugin list shown above, write a route that returns a data class, and add a testApplication test to gain confidence without launching a server. As your API grows, you can incrementally add DI, validation, or OpenAPI support—each as an explicit install step, keeping the mental model of “pay for what you use”. This approach yields a small, understandable codebase while still leveraging Kotlin’s coroutines and serialization ecosystem.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.