In the previous tutorial, we built routes that return plain text. But real APIs use JSON. Clients send JSON requests and expect JSON responses.

In this tutorial, you will add JSON serialization to your Ktor API using kotlinx.serialization — the official Kotlin serialization library.

What is Content Negotiation?

When a client sends a request, it tells the server what format it wants using the Accept header. When it sends data, it uses the Content-Type header.

Client → Server:
  Content-Type: application/json
  Accept: application/json
  Body: {"name": "Alex", "email": "alex@example.com"}

Server → Client:
  Content-Type: application/json
  Body: {"id": 1, "name": "Alex", "email": "alex@example.com"}

Content negotiation is the process of matching these formats. Ktor handles this with the ContentNegotiation plugin.

Setup

You need two dependencies in your build.gradle.kts:

plugins {
    kotlin("plugin.serialization") version "2.3.0"
}

dependencies {
    implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
}

The serialization plugin generates serializers at compile time. The Ktor dependency connects kotlinx.serialization to Ktor’s content negotiation system.

Installing the Plugin

Create plugins/Serialization.kt:

package com.kemalcodes.plugins

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

fun Application.configureSerialization() {
    install(ContentNegotiation) {
        json(Json {
            prettyPrint = true
            isLenient = false
            ignoreUnknownKeys = true
        })
    }
}

Then install it in your module:

fun Application.module() {
    configureSerialization()  // Install before routes
    configureStatusPages()
    configureRouting()
}

Configuration Options

OptionDefaultWhat It Does
prettyPrintfalseFormats JSON with indentation
isLenientfalseAllows non-standard JSON (unquoted keys, etc.)
ignoreUnknownKeysfalseIgnores extra fields in requests
encodeDefaultstrueIncludes default values in output
coerceInputValuesfalseUses default values for null/invalid inputs

We set ignoreUnknownKeys = true so the API does not break when clients send extra fields. This is important for backwards compatibility.

Creating Data Models

Use @Serializable to mark data classes for JSON conversion.

Separate Request and Response Models

A common pattern is to have different models for requests and responses:

// models/User.kt
package com.kemalcodes.models

import kotlinx.serialization.Serializable

@Serializable
data class UserResponse(
    val id: Int,
    val name: String,
    val email: String
)

@Serializable
data class CreateUserRequest(
    val name: String,
    val email: String
)

@Serializable
data class ErrorResponse(
    val message: String,
    val code: Int
)

Why separate models?

  • Request models have no id field — the server generates it
  • Response models include everything the client needs
  • Error models have a consistent format for all errors

Note Models

// models/Note.kt
package com.kemalcodes.models

import kotlinx.serialization.Serializable

@Serializable
data class NoteResponse(
    val id: Int,
    val title: String,
    val content: String
)

@Serializable
data class CreateNoteRequest(
    val title: String,
    val content: String
)

@Serializable
data class UpdateNoteRequest(
    val title: String? = null,
    val content: String? = null
)

The UpdateNoteRequest uses nullable fields with defaults. This allows partial updates — you only send the fields you want to change.

Receiving JSON Requests

Use call.receive<T>() to parse the request body into a data class:

post("/api/users") {
    val request = call.receive<CreateUserRequest>()
    // request.name and request.email are now available
}

Ktor automatically:

  1. Reads the request body
  2. Checks the Content-Type header
  3. Deserializes the JSON into your data class
  4. Throws an exception if the JSON is invalid

Validation

After receiving the request, validate the data:

post("/api/users") {
    val request = call.receive<CreateUserRequest>()

    if (request.name.isBlank() || request.email.isBlank()) {
        call.respond(
            HttpStatusCode.BadRequest,
            ErrorResponse("Name and email are required", 400)
        )
        return@post
    }

    // Create the user
    val newId = (users.maxOfOrNull { it.id } ?: 0) + 1
    val user = UserResponse(newId, request.name, request.email)
    users.add(user)

    call.respond(HttpStatusCode.Created, user)
}

Sending JSON Responses

Use call.respond() to send serializable objects:

// Single object
call.respond(user)  // {"id": 1, "name": "Alex", "email": "alex@example.com"}

// List of objects
call.respond(users) // [{"id": 1, ...}, {"id": 2, ...}]

// With status code
call.respond(HttpStatusCode.Created, user)

// Error response
call.respond(
    HttpStatusCode.NotFound,
    ErrorResponse("User not found", 404)
)

Ktor automatically serializes the object to JSON and sets the Content-Type: application/json header.

Complete User Routes with JSON

Here is the full user routes file with JSON serialization:

package com.kemalcodes.routes

import com.kemalcodes.models.*
import io.ktor.http.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*

private val users = mutableListOf(
    UserResponse(1, "Alex", "alex@example.com"),
    UserResponse(2, "Sam", "sam@example.com"),
    UserResponse(3, "Jordan", "jordan@example.com")
)

fun Route.userRoutes() {
    route("/users") {
        // GET /api/users
        get {
            val nameFilter = call.queryParameters["name"]
            val result = if (nameFilter != null) {
                users.filter { it.name.contains(nameFilter, ignoreCase = true) }
            } else {
                users.toList()
            }
            call.respond(result)
        }

        // GET /api/users/{id}
        get("/{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
            if (id == null) {
                call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid ID", 400))
                return@get
            }
            val user = users.find { it.id == id }
            if (user == null) {
                call.respond(HttpStatusCode.NotFound, ErrorResponse("User not found", 404))
                return@get
            }
            call.respond(user)
        }

        // POST /api/users
        post {
            val request = call.receive<CreateUserRequest>()
            if (request.name.isBlank() || request.email.isBlank()) {
                call.respond(HttpStatusCode.BadRequest, ErrorResponse("Name and email required", 400))
                return@post
            }
            val newId = (users.maxOfOrNull { it.id } ?: 0) + 1
            val user = UserResponse(newId, request.name, request.email)
            users.add(user)
            call.respond(HttpStatusCode.Created, user)
        }

        // DELETE /api/users/{id}
        delete("/{id}") {
            val id = call.parameters["id"]?.toIntOrNull()
            if (id == null) {
                call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid ID", 400))
                return@delete
            }
            val removed = users.removeIf { it.id == id }
            if (removed) {
                call.respond(HttpStatusCode.NoContent)
            } else {
                call.respond(HttpStatusCode.NotFound, ErrorResponse("User not found", 404))
            }
        }
    }
}

JSON Error Responses

Update the StatusPages plugin to return JSON errors:

fun Application.configureStatusPages() {
    install(StatusPages) {
        exception<Throwable> { call, cause ->
            call.respond(
                HttpStatusCode.InternalServerError,
                ErrorResponse(
                    message = cause.message ?: "Internal server error",
                    code = 500
                )
            )
        }

        status(HttpStatusCode.NotFound) { call, status ->
            call.respond(
                status,
                ErrorResponse(message = "Not found", code = 404)
            )
        }
    }
}

Now all errors return consistent JSON:

{
    "message": "User not found",
    "code": 404
}

Testing with JSON

For tests, install ContentNegotiation on the test client:

testImplementation("io.ktor:ktor-client-content-negotiation:$ktorVersion")

Then create a client with JSON support:

@Test
fun `create user with JSON body`() = testApplication {
    application { module() }
    val client = createClient {
        install(ContentNegotiation) { json() }
    }
    val response = client.post("/api/users") {
        contentType(ContentType.Application.Json)
        setBody(CreateUserRequest("Taylor", "taylor@example.com"))
    }
    assertEquals(HttpStatusCode.Created, response.status)
    val user = response.body<UserResponse>()
    assertEquals("Taylor", user.name)
    assertEquals("taylor@example.com", user.email)
}

Reading JSON Responses in Tests

@Test
fun `list users returns JSON array`() = testApplication {
    application { module() }
    val client = createClient {
        install(ContentNegotiation) { json() }
    }
    val users = client.get("/api/users").body<List<UserResponse>>()
    assertTrue(users.isNotEmpty())
    assertEquals("Alex", users.first().name)
}

The body<T>() function deserializes the response into your data class. Clean and type-safe.

Common Serialization Patterns

Optional Fields with Defaults

@Serializable
data class UpdateNoteRequest(
    val title: String? = null,
    val content: String? = null
)

Clients can send {"title": "New Title"} without content. Missing fields get the default value (null).

Custom Field Names

@Serializable
data class UserResponse(
    val id: Int,
    @SerialName("full_name") val name: String,
    @SerialName("email_address") val email: String
)

@SerialName maps Kotlin property names to different JSON field names.

Enum Serialization

@Serializable
enum class Priority {
    LOW, MEDIUM, HIGH
}

@Serializable
data class NoteResponse(
    val id: Int,
    val title: String,
    val priority: Priority = Priority.MEDIUM
)

Enums serialize to their name by default: "priority": "HIGH".

Date Serialization

kotlinx.serialization does not support dates by default. Use a custom serializer or serialize as a string:

@Serializable
data class NoteResponse(
    val id: Int,
    val title: String,
    val createdAt: String  // ISO 8601 format: "2026-07-10T08:00:00Z"
)

Nested Objects

You can serialize nested objects:

@Serializable
data class UserWithNotesResponse(
    val id: Int,
    val name: String,
    val email: String,
    val notes: List<NoteResponse>
)

This serializes to:

{
    "id": 1,
    "name": "Alex",
    "email": "alex@example.com",
    "notes": [
        {"id": 1, "title": "First Note", "content": "Hello"},
        {"id": 2, "title": "Second Note", "content": "World"}
    ]
}

Lists and Maps

kotlinx.serialization handles collections automatically:

@Serializable
data class TaggedNoteResponse(
    val id: Int,
    val title: String,
    val tags: List<String>,                    // ["kotlin", "ktor"]
    val metadata: Map<String, String> = emptyMap()  // {"priority": "high"}
)

Handling Different Content Types

The ContentNegotiation plugin can handle multiple formats. While JSON is the most common, you can add others:

install(ContentNegotiation) {
    json()  // application/json
    // You could also add:
    // xml()   // application/xml
    // cbor()  // application/cbor
}

The Accept header tells Ktor which format the client wants. The Content-Type header tells Ktor which format the client is sending.

Response Wrapping

Many APIs wrap responses in a standard envelope:

@Serializable
data class ApiResponse<T>(
    val success: Boolean,
    val data: T? = null,
    val error: String? = null
)

Usage:

get("/api/users/{id}") {
    val user = repository.findById(id)
    if (user != null) {
        call.respond(ApiResponse(success = true, data = user))
    } else {
        call.respond(
            HttpStatusCode.NotFound,
            ApiResponse<UserResponse>(success = false, error = "User not found")
        )
    }
}

This gives clients a consistent response format they can parse reliably.

What Happens When JSON is Invalid?

If a client sends invalid JSON, Ktor throws an exception. The StatusPages plugin catches it and returns an error response:

# Missing required field
curl -X POST http://localhost:8080/api/users \
  -H "Content-Type: application/json" \
  -d '{"name": "Alex"}'

# Response: 500 Internal Server Error
# {"message": "...", "code": 500}

In a production app, you would catch ContentTransformationException specifically and return a 400 Bad Request.

Source Code

You can find the source code for this tutorial on GitHub:

github.com/kemalcodes/ktor-tutorial — Branch: tutorial-05-serialization

What’s Next?

In the next tutorial, we will add a real database using Exposed ORM with H2. We will replace our in-memory lists with proper database tables.

Ktor Tutorial #6: Database Setup — Exposed ORM with H2