In Ktor, how do you serve a custom media type such as application/vnd.api+json?
answer
- it is a registry keyed by media type
- the built-in converters take a contentType parameter
- register binds any converter to any type
- Content-Type in, Accept out
basics
~10 sRegister a converter against that exact ContentType inside install(ContentNegotiation). Ktor's json() and jackson() both accept a contentType parameter, and register(contentType, converter) binds any ContentConverter to any media type, including several in one application.
solid answer
~40 s`ContentNegotiation` is a registry keyed by media type, so a custom type is just another key. `json(contentType = ContentType("application", "vnd.api+json"))` registers the kotlinx converter under that type; add a plain `json()` alongside if you also answer `application/json`. For a format no shipped converter handles, implement `ContentConverter` — its two responsibilities are serializing a value to outgoing content and deserializing the request channel into a requested type — and bind it with `register(myContentType, MyConverter())`. Selection then works as usual: a request whose `Content-Type` is your custom type is deserialized by your converter, and `call.respond(obj)` matches the request's `Accept` header against the registered types and stamps the winning media type on the response. That is also the mechanism behind vendor media types used for API versioning — the routing code is unchanged; only the registration differs.
code
kotlin · 7 linesval VndApiJson = ContentType("application", "vnd.api+json")
install(ContentNegotiation) {
json(contentType = VndApiJson) // vendor dialect
json() // plain application/json
register(ContentType.Application.Xml, LegacyXmlConverter())
}go deeper
Recall that the media type is a parameter of the registration, not a fixed part of the plugin — json() takes a contentType argument.
Explain the registry model: converters are bound per media type, Content-Type selects on receive, Accept selects on respond and sets the response type.
Show when you would actually do this — vendor types, a non-JSON format alongside JSON, or different serializer settings per surface — and know that a custom ContentConverter handles serialize and deserialize.
Own whether vendor media types belong in your platform at all, given the client, cache and gateway support they demand, versus keeping one media type and versioning elsewhere.
## The registry, not the JSON library, is the extension point People read `install(ContentNegotiation) { json() }` as "turn on JSON". It is more precise to read it as "bind the kotlinx converter to the media type `application/json`". Once you see the plugin as a `ContentType -> ContentConverter` map, custom media types stop being a special feature. Two ways to add an entry: **Reuse a shipped converter under a different type.** Both `json(...)` and `jackson(...)` take a `contentType` parameter. `json(contentType = ContentType("application", "vnd.api+json"))` serves a vendor JSON dialect with the ordinary kotlinx converter. Registrations accumulate, so you can serve the same DTOs under a vendor type and under plain `application/json` in the same application. **Write a converter.** `ContentConverter` is the interface behind every registration. A converter is asked to serialize a value into outgoing content for a given content type and charset, and to deserialize the request's byte channel into a requested type described by Ktor's `TypeInfo`. Returning null from serialization means "not my content type", which lets Ktor fall through to another registration. Bind it with `register(ContentType("application", "x-protobuf"), ProtobufConverter())` — or `register(contentType, converter) { }` when your converter has its own configuration block. ## How selection actually resolves On **receive**, the request's `Content-Type` header selects the converter. A caller sending `Content-Type: application/vnd.api+json` reaches the converter registered under exactly that type; a caller sending `application/json` reaches the other one. Neither route code nor DTOs change. On **respond**, the request's `Accept` header is matched against the registered content types and the winner both serializes the body and becomes the response `Content-Type`. This is what makes vendor media types usable for versioning: the same handler answers `Accept: application/vnd.myapi.v2+json` and `Accept: application/json` with different registrations, and clients that send no `Accept` fall back to whatever the single or default registration is. ## Where this pattern earns its keep - **Vendor media types.** A dialect of JSON with a documented envelope, or a per-version media type, that must not be conflated with generic `application/json` by intermediaries and clients. - **Non-JSON formats.** Protobuf, CBOR or a legacy XML endpoint served alongside the JSON API, each behind its own converter. - **Different serializer settings per surface.** A public API that must not leak default values and an internal API that includes them can be two registrations backed by differently configured `Json` instances under different media types. ## Cautions Do not register two converters for the same media type and rely on a particular one winning — the mapping is by content type and duplicate keys make behaviour depend on registration order, which is exactly the kind of implicit rule that breaks on refactor. Keep one converter per media type. Remember that Ktor's client has its own `ContentNegotiation` plugin with the same name in `io.ktor.client.plugins.contentnegotiation`. If your service's clients are also Ktor, they need the mirrored registration for the custom type or their typed `body<T>()` call will find no converter for the media type you returned. Finally, keep the custom type in one place. A `ContentType("application", "vnd.api+json")` constructed inline in three files eventually drifts by a character; declare it once as a constant and reference it in the registration, in tests, and anywhere routes assert on it. ## The interview point The question checks whether you understand the plugin as a registry rather than a JSON switch. A strong answer names the `contentType` parameter on the built-in converters, names `register(contentType, converter)` and `ContentConverter` for the general case, and explains that `Content-Type` drives receive while `Accept` drives respond — without wandering into whether vendor media types are a good versioning strategy, which is a design question the API itself answers.
- Can one Ktor application serve both a vendor media type and plain application/json?Yes. Registrations accumulate, so `json(contentType = ContentType("application", "vnd.api+json"))` and `json()` can coexist. Receives are routed by the request `Content-Type`, responses by matching `Accept` against the registered types, and the matched type is written back as the response `Content-Type`.
- What are the two responsibilities of a custom ContentConverter?Turning a value plus a target content type and charset into outgoing content for the response, and turning the request's byte channel into an instance of the type described by Ktor's `TypeInfo` on receive. Returning null from serialization signals that the converter does not handle that content type, letting another registration take it.
saying these in an interview costs you the question
- Thinking ContentNegotiation only supports application/json
- Registering two converters for the same media type
- Assuming routes must change to serve a new media type
- Forgetting Ktor clients need the mirrored registration
- Rebuilding the same ContentType inline in many files