Serving JSON and XML from One Ktor Endpoint with Content Negotiation
Ktor's ContentNegotiation plugin lets one route serve JSON and XML based on the client's Accept header. Here's how to configure it, when to override it per route, and how to test the negotiation contract.
11 Sept 2025, 22:19 UTC

You have a REST API in Ktor that returns JSON. Then a partner integration shows up that only speaks XML, and suddenly you're tempted to duplicate every route with a /xml suffix. Don't. Ktor's content negotiation already solves this: one route, one handler, and the response format is chosen from the client's Accept header at runtime.
The takeaway: register your serializers once in a ContentNegotiation block, return plain Kotlin objects from your handlers, and let Ktor pick the format. The client decides; your code stays singular.
What content negotiation actually does
When a request arrives, Ktor reads the Accept header (for example, Accept: application/xml), looks up which serializers you registered for which media types, and picks the best match. It then serializes your return value with that formatter and sets the response Content-Type accordingly. Because Ktor handlers are suspending functions, this resolution happens without blocking a thread.
If no registered formatter matches the Accept header, Ktor responds with 406 Not Acceptable — which is the correct HTTP behavior, and much better than silently returning JSON to a client that asked for XML.
A worked example: one route, two formats
Assume Ktor 2.x (the 3.x line keeps the same general shape, but always re-test custom serializers after a major upgrade). Add the serialization dependencies, then configure the plugin during application construction:
fun Application.module() {
install(ContentNegotiation) {
json(Json {
prettyPrint = true
ignoreUnknownKeys = true
})
xml(format = XML {
indent = 4
})
}
routing {
get("/orders/{id}") {
val order = Order(
id = call.parameters["id"]!!,
total = BigDecimal("49.99"),
status = "SHIPPED"
)
call.respond(order)
}
}
}The handler doesn't know or care which format the client wants. It returns an Order object, and the plugin handles the rest:
# JSON client
curl -H "Accept: application/json" http://localhost:8080/orders/42
# Content-Type: application/json
# XML client
curl -H "Accept: application/xml" http://localhost:8080/orders/42
# Content-Type: application/xml; charset=UTF-8One important constraint: install ContentNegotiation inside your module function, before the server starts. Registering formatters after startup throws an exception, so treat serializer configuration as construction-time work, not something you toggle per request.
Per-route overrides when global defaults aren't enough
Sometimes one endpoint needs different rules — say, a legacy export route that must always emit XML regardless of what the client asks for. Rather than fighting the global config, handle that route explicitly: serialize the payload yourself with the XML formatter and respond with call.respondText(text, ContentType.Application.Xml). Keep the global negotiation for the common case and bypass it only where the contract demands a fixed format. This keeps the escape hatch visible in code review instead of buried in plugin configuration.
The trade-off you should know about
Supporting multiple formats is not free. Every serializer you register is another code path your API contract must satisfy. A field that serializes cleanly to JSON may produce awkward XML (attributes vs. elements, list wrapping, null handling), and clients will notice. Before advertising XML support, write a test per format per endpoint, and be honest about whether your consumers actually need the second format — a JSON-only API with a clear 406 response is a legitimate, often better, design.
How to verify it works
Don't trust the configuration by reading it — test the negotiation itself. Ktor's testApplication makes this cheap:
@Test
fun `negotiates xml when requested`() = testApplication {
application { module() }
val response = client.get("/orders/42") {
header(HttpHeaders.Accept, "application/xml")
}
assertEquals(ContentType.Application.Xml.withCharset(Charsets.UTF_8),
response.contentType())
}Run this where you run the rest of your test suite; it needs no special permissions and starts the app in-memory. Add a second case asserting that an unsupported Accept value (like text/csv) returns 406, and a third for the JSON default. Those three tests pin down the entire negotiation contract.
Closing
Content negotiation turns "we need another format" from a routing problem into a configuration line. Register your serializers at startup, return domain objects, and write one test per supported media type. If you only have one real consumer, resist the urge to register formats speculatively — the Accept header will still be there when a genuine second client shows up.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.