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
| Option | Default | What It Does |
|---|---|---|
prettyPrint | false | Formats JSON with indentation |
isLenient | false | Allows non-standard JSON (unquoted keys, etc.) |
ignoreUnknownKeys | false | Ignores extra fields in requests |
encodeDefaults | true | Includes default values in output |
coerceInputValues | false | Uses 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
idfield — 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:
- Reads the request body
- Checks the
Content-Typeheader - Deserializes the JSON into your data class
- 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
Related Articles
- Ktor Tutorial #4: Routing — HTTP request handling
- Ktor Tutorial #3: Project Setup — Application structure
- Kotlin Tutorial: Complete Series — Learn Kotlin from scratch