Speaking JSON: Content Negotiation in Ktor


APIs speak JSON. So far we’ve been pushing raw strings around, which is fine for a demo and miserable for a real service. Ktor’s ContentNegotiation plugin closes the gap: you work with Kotlin data classes, and the plugin handles JSON on the wire in both directions.

The plugin

Install it once in your module, configured to use JSON:

import io.ktor.server.application.*
import io.ktor.server.plugins.contentnegotiation.*
import io.ktor.serialization.kotlinx.json.*

fun Application.module() {
    install(ContentNegotiation) {
        json()
    }
    routing {
        // ...
    }
}

install(Plugin) { ... } is the pattern for every Ktor plugin — you’ll see it constantly. The block configures the plugin; here json() registers JSON support backed by kotlinx.serialization (the dependency the project generator added for you).

“Content negotiation” is the HTTP mechanism behind the name: the client says what it wants via the Accept header and what it’s sending via Content-Type, and the plugin picks the matching format. With only json() registered, that format is JSON.

Serializable data classes

kotlinx.serialization works at compile time, so it needs your models marked @Serializable:

import kotlinx.serialization.Serializable

@Serializable
data class Task(
    val id: Int,
    val title: String,
    val done: Boolean = false,
)

That annotation is the only ceremony. No getters, no Jackson config, no reflection — the serialization plugin generates the JSON code for this class at build time.

Responding with objects

Now call.respond takes your object directly and the plugin turns it into JSON:

get("/tasks/{id}") {
    val task = Task(id = 1, title = "Write the JSON post", done = true)
    call.respond(task)
}

The response body becomes:

{ "id": 1, "title": "Write the JSON post", "done": true }

Collections work the same way — return a List<Task> and you get a JSON array:

get("/tasks") {
    val tasks = listOf(
        Task(1, "First", done = true),
        Task(2, "Second"),
    )
    call.respond(tasks)
}

Receiving objects

The reverse is just as clean. call.receive<T>() reads the request body and deserializes it into your type:

post("/tasks") {
    val newTask = call.receive<Task>()
    call.respond(HttpStatusCode.Created, newTask)
}

Send this body with Content-Type: application/json:

{ "id": 9, "title": "Learn content negotiation" }

…and newTask is a fully-typed Task. Note done was omitted — it falls back to the = false default from the data class. That’s a genuinely nice property: your Kotlin defaults are your API’s defaults.

A model for input

Notice the awkwardness above: the client had to send an id, but the server should own ids. The fix is a separate model for incoming data — one without an id:

@Serializable
data class NewTask(val title: String, val done: Boolean = false)

post("/tasks") {
    val draft = call.receive<NewTask>()
    val created = Task(id = 42, title = draft.title, done = draft.done)
    call.respond(HttpStatusCode.Created, created)
}

Now the client sends { "title": "..." } and the server assigns the id. Separating the request model from the stored model is a habit worth forming early — they drift apart fast once auth, timestamps, and computed fields enter the picture.

Tuning the JSON

The json() call accepts a configured Json instance when you want different behavior:

import kotlinx.serialization.json.Json

install(ContentNegotiation) {
    json(Json {
        prettyPrint = true        // indented output, nice in dev
        ignoreUnknownKeys = true  // don't fail on extra fields in the request
    })
}

ignoreUnknownKeys = true is the one most people want in production — it stops the parser from throwing when a client sends a field your model doesn’t know about, which makes your API far more tolerant as it evolves.

Final thoughts

Content negotiation is the moment Ktor starts feeling like a real API framework: one install, one annotation per model, and the JSON boundary disappears — you write and read Kotlin objects on both ends. The one design call worth making deliberately is splitting request models from stored ones, so your wire format and your internal types can evolve on their own schedules.

We now have all the pieces. Next: building a REST API — full CRUD over a task resource, with proper status codes, wired together into something you’d actually call.

Comments