Content and Codable

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Vapor 4.122.2 on Swift 6.4 (the package builds in the Swift 6 language mode; Vapor 4.122.2 itself needs tools version 6.0), with MultipartKit as resolved by that Vapor release, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings in one scratch package, and the curl output was captured from that package running on macOS (JSON key order in the captured bodies is not guaranteed, so do not depend on it). Vapor 5 is at 5.0.0-beta.4 and is not released yet; this page describes Vapor 4. The Swift side of the same machinery (Codable, JSONEncoder, coding keys, date strategies) is explained in Codable & Serialization; this page is about what Vapor adds on top: the Content protocol, the registry that maps media types to coders, and the request and response containers.

What this page covers and where it sits in the request

Vapor does not parse bodies for you in the middleware. A request travels through the middleware chain, the router matches a route, and the route’s body strategy decides whether the body is collected into memory (the default, up to routes.defaultMaxBodySize, which is 16 kB unless you change it) or streamed. Only then does your handler run, and decoding is something the handler (or the handler signature) asks for explicitly:

flowchart LR A["HTTP request"] --> B["Middleware chain"] B --> C["Router matches route"] C --> D["Body collected up to max size"] D --> E["Handler: req.content.decode"] E --> F{"Content-Type"} F -->|"application/json"| G["JSONDecoder"] F -->|"x-www-form-urlencoded"| H["URLEncodedFormDecoder"] F -->|"multipart/form-data"| I["FormDataDecoder"] F -->|"text/plain"| J["PlaintextDecoder"] F -->|"anything else"| K["415 Unsupported Media Type"] G --> L["Your Content value"] H --> L I --> L J --> L L --> M["Handler returns a Content value"] M --> N["Encoder chosen by defaultContentType"] N --> O["Response with Content-Type header"]

The request side is req.content (the body) and req.query (the URL query string); the response side is res.content, or simply returning a Content value from the handler, which Vapor turns into a Response. All of them go through one registry, the global ContentConfiguration, that maps an HTTPMediaType to an encoder or decoder. Everything else on this page is a variation on that picture.

The Content protocol

Content is the protocol that makes a Codable type usable as a request body, a response body or a query string:

public protocol Content: Codable, RequestDecodable, ResponseEncodable,
    AsyncRequestDecodable, AsyncResponseEncodable, Sendable
{
    static var defaultContentType: HTTPMediaType { get }
    mutating func beforeEncode() throws
    mutating func afterDecode() throws
}

(The listing above is a trimmed copy of the declaration to show the requirements; the real file also carries documentation comments.)

  • Codable makes the type encodable and decodable by any Encoder/Decoder. A conforming type is usually a plain struct whose stored properties are themselves Codable.

  • ResponseEncodable and AsyncResponseEncodable are what let a route closure return the value directly: Vapor calls encodeResponse(for:), which builds a Response and runs response.content.encode(self).

  • RequestDecodable and AsyncRequestDecodable are what let a handler take the value as a parameter instead of the Request (func create(book: CreateBook)), in the closure and use: overloads that accept them.

  • Sendable is required, and is the reason a Content struct in Swift 6 mode needs Sendable stored properties (see Swift 6 concurrency notes).

  • defaultContentType is the media type used when the value is encoded without an explicit one. The default implementation returns .json.

  • beforeEncode() and afterDecode() are hooks with empty default implementations, covered in the hooks section.

Vapor already provides the conformance for the common standard types, so you can return them from a handler without wrapping:

Type Default media type Notes

String

text/plain; charset=utf-8

a plain-text body

Int, Int8 …​ UInt64, Double, Float

text/plain; charset=utf-8

the number written as text

Bool

application/json

true or false

Array of Content

application/json

a JSON array

Dictionary with String keys and Content values

application/json

a JSON object

Two practical consequences. A route that returns [BookResponse] already produces a JSON array, with no wrapper type. And a route that returns String produces text/plain, so a String is not the way to return JSON text: return a Content value or build a Response.

Decoding a request body

The body of a request is decoded with req.content. The container offers several entry points, all defined in ContentContainer:

Call Behavior

req.content.decode(T.self)

picks the decoder from the request Content-Type, decodes T, and for a Content type then calls afterDecode(); throws on any failure

req.content.decode(T.self, as: .json)

ignores the header and uses the decoder registered for the given media type

req.content.decode(T.self, using: decoder)

uses a decoder you provide, once, without registering it

req.content.get(T.self, at: "a", "b")

decodes only the value at a path of keys (and array indexes) and throws if it is missing or has the wrong type

req.content[T.self, at: path] or req.content["name"]

the same, but returns nil instead of throwing

req.content.contentType

the parsed Content-Type header, or nil

The first example is the Bookshelf create-book endpoint. CreateBook is the request DTO, BookResponse the response DTO, and the controller is a RouteCollection:

import Vapor

/// The request body of POST /books.
struct CreateBook: Content {
    var title: String
    var author: String
    var year: Int?
    var tags: [String] = []
}

/// What the API returns for a book.
struct BookResponse: Content {
    var id: UUID
    var title: String
    var author: String
    var year: Int?
    var tags: [String]
    var createdAt: Date
}

struct BookEndpoints: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        let books = routes.grouped("books")
        books.post(use: create)
        books.post("echo-title", use: echoTitle)
        books.post("optional", use: optionalFields)
    }

    @Sendable
    func create(req: Request) async throws -> Response {
        let input = try req.content.decode(CreateBook.self)
        let book = BookResponse(
            id: UUID(), title: input.title, author: input.author,
            year: input.year, tags: input.tags, createdAt: Date(timeIntervalSince1970: 1_767_225_600))
        // Encode into a Response with an explicit status; Content picks application/json.
        let response = Response(status: .created)
        try response.content.encode(book)
        response.headers.replaceOrAdd(name: .location, value: "/books/\(book.id)")
        return response
    }

    /// Read one nested value without declaring a whole struct.
    @Sendable
    func echoTitle(req: Request) async throws -> String {
        let title = try req.content.get(String.self, at: "book", "title")
        let firstTag: String? = req.content[at: "book", "tags", 0]
        return "\(title) / \(firstTag ?? "no tags")"
    }

    /// Show the difference between get(at:) (throws) and the subscript (nil).
    @Sendable
    func optionalFields(req: Request) async throws -> [String: String] {
        let year: Int? = req.content["year"]
        let strict = try? req.content.get(Int.self, at: "year")
        return ["subscript": year.map(String.init) ?? "nil", "get": strict.map(String.init) ?? "threw"]
    }
}

The handlers are marked @Sendable because they are passed as use: function references (see the concurrency notes). With the server running, a valid POST /books produces a 201 Created, a Location header and the encoded BookResponse:

$ curl -si -X POST localhost:8080/books -H 'Content-Type: application/json' \
    -d '{"title":"Dune","author":"Frank Herbert","year":1965,"tags":["sf"]}'
HTTP/1.1 201 Created
content-type: application/json; charset=utf-8
content-length: 146
location: /books/308B420F-9485-4F29-9103-8C9B5FE7B2C9

{"id":"308B420F-9485-4F29-9103-8C9B5FE7B2C9","year":1965,"tags":["sf"],"title":"Dune","author":"Frank Herbert","createdAt":"2026-01-01T00:00:00Z"}

Three things to notice in the example:

  • The optional year and the defaulted tags make those fields optional in the JSON. A required stored property (title, author) that is missing makes decoding fail; this is ordinary Codable behavior.

  • Response(status: .created) followed by response.content.encode(book) is the explicit form of returning a value with a status other than 200. The shorter form, returning book, always yields 200 unless you wrap it in a response.

  • Decoding is a synchronous, throwing call in Vapor 4. There is no await, because the body is already in memory when the handler runs. If the body is being streamed (the route used .stream), decode fails because there is no collected body; collect it or read the stream yourself.

Reading one value without a struct: get(at:) and the subscript

get(at:) walks a path of keys and indexes, which is convenient for a single field of a larger payload or for a body whose shape you do not want to model. The throwing form is for required values, the subscript for optional ones:

$ curl -s -X POST localhost:8080/books/echo-title -H 'Content-Type: application/json' \
    -d '{"book":{"title":"Dune","tags":["sf","classic"]}}'
Dune / sf

$ curl -s -X POST localhost:8080/books/optional -H 'Content-Type: application/json' -d '{"year":1965}'
{"get":"1965","subscript":"1965"}

$ curl -s -X POST localhost:8080/books/optional -H 'Content-Type: application/json' -d '{"year":"abc"}'
{"get":"threw","subscript":"nil"}

A path element is a String (a key) or an Int (an array index), because both conform to CodingKeyRepresentable. The subscript hides every error, including a value of the wrong type, as nil; use get when you need to tell "missing" from "malformed".

Which decoder runs, and 415

decode(_:) does not look at the type you ask for. It looks at the request Content-Type header and asks ContentConfiguration.global for the decoder registered for that media type. The registry matches on the type and subtype, so parameters such as charset do not matter. Out of the box these decoders are registered:

Media type Decoder Typical use

application/json (and application/vnd.api+json)

JSONDecoder with ISO-8601 dates

API clients, fetch, mobile apps

application/x-www-form-urlencoded

URLEncodedFormDecoder

HTML <form> posts without files

multipart/form-data

FormDataDecoder (MultipartKit)

forms with file uploads

text/plain

PlaintextDecoder

a single String, Int or other single value

text/html has an encoder only; there is no HTML decoder. If no decoder is registered for the header, or there is no Content-Type at all, decode throws an Abort(.unsupportedMediaType), and the default error middleware turns it into a 415 Unsupported Media Type response. These bodies were captured from the example above:

$ curl -si -X POST localhost:8080/books -H 'Content-Type: text/xml' -d '<a/>'
HTTP/1.1 415 Unsupported Media Type

{"reason":"Support for reading media type 'text\/xml' has not been configured.","error":true}

$ curl -si -X POST localhost:8080/books
HTTP/1.1 415 Unsupported Media Type

{"reason":"Can't decode data without a content type","error":true}

A request with a JSON content type but an empty body gets a 422 Unprocessable Entity instead (the container finds no collected body). When a failure is a DecodingError (a missing key, a wrong type, malformed JSON) the default error middleware answers 400 Bad Request with the decoder’s message in reason, as in the common-errors table at the end of this page.

JSON

JSON is the default for everything. The decoder and encoder Vapor registers are plain JSONDecoder and JSONEncoder instances configured with ISO-8601 dates, so a Date property round-trips as "2026-01-01T00:00:00Z" with no configuration (as in the createdAt field above). Vapor does not change the key strategy: a property called createdAt is createdAt in the JSON. To get created_at, either map it with CodingKeys on a type or change the key strategy for the whole application, both shown below.

URL-encoded forms and multipart forms

The same Content struct decodes from JSON, from a URL-encoded form and from a multipart form, because the decoder is chosen by the header. ContactForm is decoded identically from all three:

import Vapor

struct ContactForm: Content {
    var name: String
    var email: String
    var message: String
}

struct CoverUpload: Content {
    var title: String
    var cover: File
}

struct MediaTypeEndpoints: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        let media = routes.grouped("media")
        media.post("contact", use: contact)
        media.on(.POST, "cover", body: .collect(maxSize: "2mb"), use: cover)
        media.post("text", use: text)
        media.post("force-json", use: forceJSON)
        media.get("page", use: page)
    }

    @Sendable
    func contact(req: Request) async throws -> HTTPStatus {
        // Works for application/json AND x-www-form-urlencoded: the decoder is picked by Content-Type.
        let form = try req.content.decode(ContactForm.self)
        req.logger.info("Contact from \(form.name)")
        return .accepted
    }

    @Sendable
    func cover(req: Request) async throws -> String {
        let upload = try req.content.decode(CoverUpload.self)
        return "\(upload.title): \(upload.cover.filename) (\(upload.cover.data.readableBytes) bytes, \(upload.cover.contentType?.serialize() ?? "unknown"))"
    }

    @Sendable
    func text(req: Request) async throws -> String {
        let body = try req.content.decode(String.self)
        return String(body.reversed())
    }

    @Sendable
    func forceJSON(req: Request) async throws -> ContactForm {
        // Ignore the Content-Type header and decode as JSON.
        try req.content.decode(ContactForm.self, as: .json)
    }

    @Sendable
    func page(req: Request) async throws -> Response {
        var headers = HTTPHeaders()
        headers.contentType = .html
        return Response(status: .ok, headers: headers, body: .init(string: "<h1>Bookshelf</h1>"))
    }
}
$ curl -si -X POST localhost:8080/media/contact -H 'Content-Type: application/json' \
    -d '{"name":"Ann","email":"a@example.invalid","message":"hi"}'
HTTP/1.1 202 Accepted

$ curl -si -X POST localhost:8080/media/contact \
    -d 'name=Ann&email=a%40example.invalid&message=hello+there'
HTTP/1.1 202 Accepted

$ curl -si -X POST localhost:8080/media/contact -F name=Ann -F email=a@example.invalid -F message=hi
HTTP/1.1 202 Accepted

(curl -d sends application/x-www-form-urlencoded by default, and curl -F sends multipart/form-data.) Specifics worth knowing:

  • Returning an HTTPStatus (here .accepted) is the shortest way to answer with a status and no body.

  • Multipart bodies can carry files. A property of type File receives the filename and the bytes (ByteBuffer) of the part, and File.contentType derives a media type from the filename extension. The upload route above raises the collected-body limit to 2 MB with .collect(maxSize: "2mb"), because the default of 16 kB is too small for most files. For large uploads, stream the body to disk instead of collecting it; the File Uploads and Streaming page covers that.

  • File is Codable with two keys, data and filename, so a Content type with a File property is meant for incoming multipart forms; it is not a convenient type for a JSON response.

$ curl -s -X POST localhost:8080/media/cover -F title=Dune -F cover=@cover.png
Dune: cover.png (7 bytes, image/png)
  • A URL-encoded body can also carry arrays and nested values (tag[]=a&tag[]=b, author[name]=Ann); the options are in the next section because the same coder reads query strings.

  • decode(_:as:) forces a decoder regardless of the header. forceJSON above decodes a body sent without a Content-Type, which curl -d labels as a URL-encoded form:

$ curl -s -X POST localhost:8080/media/force-json -d '{"name":"Ann","email":"e@example.invalid","message":"m"}'
{"message":"m","email":"e@example.invalid","name":"Ann"}

Plain text

PlaintextDecoder reads the whole body as UTF-8 text and can decode a single-value type: String, a number, a Bool, or any type whose Decodable implementation uses a single-value container. Decoding a struct (a keyed container) from text/plain fails with a 400 ("Plaintext decoding does not support dictionaries"), which is what a mismatch of header and type looks like in practice:

$ curl -s -X POST localhost:8080/media/text -H 'Content-Type: text/plain' -d 'stressed'
desserts

$ curl -si -X POST localhost:8080/media/contact -H 'Content-Type: text/plain' -d 'x'
HTTP/1.1 400 Bad Request

{"reason":"Value was not of type 'Dictionary<String, Decodable>' at path ''. Plaintext decoding does not support dictionaries.","error":true}

Encoding a response

There are three equivalent ways to produce a response body, from least to most explicit:

  1. Return a Content value (or an array or dictionary of them) from the handler. Vapor encodes it with the encoder registered for defaultContentType: JSON for a struct, plain text for a String or number.

  2. Create a Response, then try response.content.encode(value) as in the create handler above. Use this to set a status, headers or cookies together with the body.

  3. Call response.content.encode(value, as: mediaType) to choose the media type, or response.content.encode(value, using: encoder) to supply a one-off encoder.

The as: form is how a single type can be served in several formats. HTML is written as a string with the right header, which is what Response does when you build it from a string body and set headers.contentType = .html:

$ curl -si localhost:8080/media/page
HTTP/1.1 200 OK
content-type: text/html; charset=utf-8

<h1>Bookshelf</h1>

(Template rendering with Leaf produces a View, which is encoded as HTML in the same way; it belongs to the Leaf Templates page.) The same ContentContainer is available on the Request as well as on a Response, so a test can build a request body with req.content.encode(...).

The URL query string

req.query is a URLQueryContainer, the query-string twin of req.content, with the same shape: decode, get(at:), a subscript, and encode. It always uses the URL query coders, regardless of any Content-Type header. The decoder is URLEncodedFormDecoder, so a query decodes the same way as a URL-encoded body.

import Vapor

struct BookSearch: Content {
    var q: String?
    var page: Int?
    var perPage: Int?
    var tag: [String]?
    var inStock: Bool?
    var since: Date?

    enum CodingKeys: String, CodingKey {
        case q, page, tag, since
        case perPage = "per_page"
        case inStock = "in_stock"
    }
}

struct SearchEndpoints: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        routes.get("search", use: search)
        routes.get("search", "raw", use: raw)
        routes.get("search", "encode", use: encode)
    }

    @Sendable
    func search(req: Request) async throws -> BookSearch {
        var query = try req.query.decode(BookSearch.self)
        query.page = max(query.page ?? 1, 1)
        return query
    }

    @Sendable
    func raw(req: Request) async throws -> [String: String] {
        let page = req.query[Int.self, at: "page"] ?? 1
        let sort: String = req.query["sort"] ?? "title"
        let limit = try req.query.get(Int.self, at: "limit")
        return ["page": "\(page)", "sort": sort, "limit": "\(limit)"]
    }

    @Sendable
    func encode(req: Request) async throws -> String {
        var uri = URI(path: "/search")
        try URLEncodedFormEncoder(configuration: .init(arrayEncoding: .values))
            .encode(BookSearch(q: "swift", page: 2, tag: ["a", "b"], inStock: true), to: &uri)
        return uri.string
    }
}

func configureQueryCoders() {
    ContentConfiguration.global.use(
        urlDecoder: URLEncodedFormDecoder(configuration: .init(
            boolFlags: true,
            arraySeparators: [","],
            dateDecodingStrategy: .iso8601)))
    ContentConfiguration.global.use(
        urlEncoder: URLEncodedFormEncoder(configuration: .init(
            arrayEncoding: .values,
            dateEncodingStrategy: .iso8601)))
}

Captured from the example:

$ curl -s 'localhost:8080/search?q=swift&page=0&per_page=20&tag=a,b&in_stock&since=2026-01-01T00:00:00Z'
{"in_stock":true,"page":1,"tag":["a","b"],"since":"2026-01-01T00:00:00Z","q":"swift","per_page":20}

$ curl -s 'localhost:8080/search?tag=a&tag=b'
{"page":1,"tag":["a","b"]}

$ curl -s 'localhost:8080/search?page=abc'
{"reason":"Value was not of type 'Int' at path 'page'. Data found at 'page' was not Int.","error":true}

$ curl -si localhost:8080/search/raw
HTTP/1.1 400 Bad Request

{"reason":"No value found (expected type 'Int') at path 'limit'. No Int was found at 'limit'.","error":true}

$ curl -s localhost:8080/search/encode
/search?in_stock=true&page=2&tag=a&tag=b&q=swift

The decoder and encoder have options (URLEncodedFormDecoder.Configuration, URLEncodedFormEncoder.Configuration) that you set through configureQueryCoders() above:

Option Default Meaning

decoder boolFlags

true

a bare key such as in_stock (no =) decodes as true for a Bool? property; absence decodes as nil

decoder arraySeparators

comma and pipe

tag=a,b and tag=a|b both decode as an array; pass a smaller set, or an empty one, if a comma is legitimate data

decoder dateDecodingStrategy

.secondsSince1970

the default reads a number; .iso8601 reads 2026-01-01T00:00:00Z, and a date-only 2026-01-01 is rejected as "Unable to decode ISO-8601 date"

encoder arrayEncoding

.bracket

tag[]=a&tag[]=b; .values writes tag=a&tag=b, .separator(",") writes tag=a,b

encoder dateEncodingStrategy

.secondsSince1970

.iso8601 and .custom are the alternatives

Notes:

  • use(urlDecoder:) and use(urlEncoder:) configure the query only. The URL-encoded body decoder is registered separately with use(decoder:for: .urlEncodedForm); set both if you want one behavior everywhere.

  • Query values are text. ?page=abc fails with a 400 when page is an Int, which is usually the right answer; clamp or default values after decoding (as search does for page) rather than relying on the client.

  • Use a CodingKeys enum (as in BookSearch) when the public query names differ from Swift names (per_page, in_stock).

  • req.query.encode(...) writes into the request URL, which is how a test client or a redirect builds a query string; the encode route above builds a URI the same way with the encoder directly.

Validation of the decoded values (a page range, a maximum page size) is the job of the Validation page; decoding only checks that the types fit.

Hooks: beforeEncode and afterDecode

afterDecode() runs once after the whole value has been decoded from a body or a query, before your handler sees it. beforeEncode() runs just before the value is encoded into a response or a request. Use them for normalization and for last-line sanitizing, not for business rules:

import Vapor

struct NewAuthor: Content {
    var name: String
    var email: String
    var website: String?

    // Called after the whole value was decoded, before the handler sees it.
    mutating func afterDecode() throws {
        name = name.trimmingCharacters(in: .whitespacesAndNewlines)
        email = email.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
        guard !name.isEmpty else {
            throw Abort(.badRequest, reason: "name must not be empty")
        }
        if let site = website, site.isEmpty {
            website = nil
        }
    }

    // Called just before the value is encoded into a response.
    mutating func beforeEncode() throws {
        email = "hidden@example.invalid"
    }
}

struct HookEndpoints: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        routes.post("authors", use: create)
    }

    @Sendable
    func create(req: Request) async throws -> NewAuthor {
        // decode(_:) runs afterDecode(); returning the value runs beforeEncode().
        try req.content.decode(NewAuthor.self)
    }
}
$ curl -si -X POST localhost:8080/authors -H 'Content-Type: application/json' \
    -d '{"name":"  Ursula  ","email":" UKL@Example.INVALID ","website":""}'
HTTP/1.1 200 OK

{"email":"hidden@example.invalid","name":"Ursula"}

$ curl -si -X POST localhost:8080/authors -H 'Content-Type: application/json' -d '{"name":"  ","email":"x"}'
HTTP/1.1 400 Bad Request

{"reason":"name must not be empty","error":true}

Points that surprise people:

  • The hooks only run for the Content overloads (decode(T.self) where T: Content, encode(content)). They do not run when a type is decoded through the generic Decodable overload, or by get(at:).

  • Throwing from afterDecode() fails the request. A thrown Abort keeps its status; any other error becomes a 500 (a DecodingError becomes 400).

  • Both are mutating, which is why the value is copied once into a var by the container.

  • The hooks are a good place for normalization (trimming, lowercasing) and a poor place for validation rules; declare those with Validatable, which reports every failing field at once and keeps the rules visible next to the model.

ContentConfiguration: choosing the coders

ContentConfiguration is the registry. ContentConfiguration.global is a lock-protected static that holds the encoders, decoders and the URL query coders, and it has the documented defaults for JSON, JSON:API, plain text, HTML (encode only), URL-encoded forms and multipart forms. You change it by registering a coder for a media type, ideally once, in configure(_:), before the server starts:

ContentConfiguration.global.use(encoder: coder, for: mediaType)
ContentConfiguration.global.use(decoder: coder, for: mediaType)
ContentConfiguration.global.use(urlEncoder: queryEncoder)
ContentConfiguration.global.use(urlDecoder: queryDecoder)

(A schematic of the four registration calls, not a program.) A lookup of a media type with no registered coder throws the 415 Abort shown earlier.

snake_case keys and a different date format for the whole app

The most common global change is switching the JSON key style, since many clients and databases expect snake_case. Register new coders for .json:

func configureSnakeCaseJSON() {
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    decoder.dateDecodingStrategy = .iso8601

    let encoder = JSONEncoder()
    encoder.keyEncodingStrategy = .convertToSnakeCase
    encoder.dateEncodingStrategy = .iso8601

    ContentConfiguration.global.use(decoder: decoder, for: .json)
    ContentConfiguration.global.use(encoder: encoder, for: .json)
}

With this call in configure, the first example answers with created_at instead of createdAt, and the request body can use created_at:

$ curl -s -X POST localhost:8080/books -H 'Content-Type: application/json' -d '{"title":"Dune","author":"F","tags":["x"]}'
{"tags":["x"],"title":"Dune","created_at":"2026-01-01T00:00:00Z","id":"7FE400BE-5245-43F9-9C0A-A5B85FAD2001","author":"F"}

For dates, JSONEncoder.custom(dates:data:keys:format:floats:userInfo:) and JSONDecoder.custom(...) are Vapor conveniences that build a coder from the strategies you name and leave the rest at the defaults:

ContentConfiguration.global.use(
    encoder: JSONEncoder.custom(dates: .millisecondsSince1970, keys: .convertToSnakeCase),
    for: .json)

Because this is global, a change here affects every route, including routes in code you do not own that decode JSON and the Fluent models you return. Decide on one convention early: either keep Swift names and map the exceptions with CodingKeys, or switch the whole app once.

One-off coders

Changing the global registry to serve one odd endpoint is a bad trade. The container methods take a coder for a single call:

import Vapor

extension HTTPMediaType {
    static let bookshelfJSON = HTTPMediaType(type: "application", subType: "vnd.bookshelf.v1+json")
}

/// A ContentEncoder/ContentDecoder pair for a JSON-based media type. The encoder stamps its own
/// Content-Type on the headers: a registered encoder, not the Content type, decides the final header.
struct JSONVariantCoder: ContentEncoder, ContentDecoder {
    let mediaType: HTTPMediaType

    private func makeEncoder() -> JSONEncoder {
        let encoder = JSONEncoder()
        encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
        encoder.keyEncodingStrategy = .convertToSnakeCase
        encoder.dateEncodingStrategy = .iso8601
        return encoder
    }

    private func makeDecoder() -> JSONDecoder {
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .iso8601
        return decoder
    }

    func encode<E: Encodable>(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders) throws {
        headers.contentType = mediaType
        try body.writeBytes(makeEncoder().encode(encodable))
    }

    func decode<D: Decodable>(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders) throws -> D {
        try makeDecoder().decode(D.self, from: Data(body.readableBytesView))
    }
}

struct Loan: Content {
    var bookTitle: String
    var dueDate: Date
}

struct CustomCoderEndpoints: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        routes.get("loans", "camel", use: camel)
        routes.get("loans", "snake", use: snake)
        routes.post("loans", "vendor", use: vendor)
    }

    @Sendable
    func camel(req: Request) async throws -> Response {
        let loan = Loan(bookTitle: "Dune", dueDate: Date(timeIntervalSince1970: 1_767_225_600))
        let response = Response()
        // One-off encoder, only for this response, without touching ContentConfiguration.
        try response.content.encode(loan, using: JSONEncoder.custom(dates: .iso8601, keys: .convertToSnakeCase))
        response.headers.contentType = .json
        return response
    }

    @Sendable
    func snake(req: Request) async throws -> Loan {
        // One-off decoder for this request only.
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .iso8601
        return try req.content.decode(Loan.self, using: decoder)
    }

    @Sendable
    func vendor(req: Request) async throws -> Response {
        let loan = try req.content.decode(Loan.self)
        let response = Response()
        try response.content.encode(loan, as: .bookshelfJSON)
        return response
    }
}

func configureVendorCoder() {
    let coder = JSONVariantCoder(mediaType: .bookshelfJSON)
    ContentConfiguration.global.use(encoder: coder, for: .bookshelfJSON)
    ContentConfiguration.global.use(decoder: coder, for: .bookshelfJSON)
}

The listing above also contains the custom coder discussed next. The one-off forms in action:

$ curl -s localhost:8080/loans/camel
{"due_date":"2026-01-01T00:00:00Z","book_title":"Dune"}

A custom ContentEncoder and ContentDecoder

ContentEncoder and ContentDecoder are the two protocols a coder implements to join the registry, and each has one requirement (the userInfo variants have default implementations that forward to the plain ones):

public protocol ContentEncoder: Sendable {
    func encode<E: Encodable>(_ encodable: E, to body: inout ByteBuffer, headers: inout HTTPHeaders) throws
}

public protocol ContentDecoder: Sendable {
    func decode<D: Decodable>(_ decodable: D.Type, from body: ByteBuffer, headers: HTTPHeaders) throws -> D
}

(Trimmed to the required methods.) The JSONVariantCoder in the previous listing implements both for a vendor media type. An encoder writes the bytes into body and sets the Content-Type header itself, because Vapor does not do it for you; that is why the .problemJSON example below needs its own coder instead of reusing JSONEncoder. A request whose Content-Type is the vendor type is then decoded with the same coder:

$ curl -si -X POST localhost:8080/loans/vendor -H 'Content-Type: application/vnd.bookshelf.v1+json' \
    -d '{"book_title":"Dune","due_date":"2026-01-01T00:00:00Z"}'
HTTP/1.1 200 OK
content-type: application/vnd.bookshelf.v1+json

{
  "book_title" : "Dune",
  "due_date" : "2026-01-01T00:00:00Z"
}

Other formats (XML, MessagePack, CSV, Protocol Buffers) follow the same pattern: implement the two protocols, register them for the media type with use(encoder:for:) and use(decoder:for:), and encode/decode as that type.

A Content type with its own media type

defaultContentType is what a type uses when it is returned from a handler. Overriding it, for instance for the RFC 9457 Problem Details media type application/problem+json, needs two pieces: the override, and a registered coder that stamps that header:

import Vapor

extension HTTPMediaType {
    static let problemJSON = HTTPMediaType(type: "application", subType: "problem+json")
}

/// A Content type with its own default media type (RFC 9457 Problem Details).
struct ProblemDetails: Content {
    static let defaultContentType = HTTPMediaType.problemJSON

    var type: String
    var title: String
    var status: Int
    var detail: String?
}

func configureProblemJSON() {
    // Vapor's JSON encoder would stamp application/json, so use a coder that sets the right header.
    let coder = JSONVariantCoder(mediaType: .problemJSON)
    ContentConfiguration.global.use(encoder: coder, for: .problemJSON)
    ContentConfiguration.global.use(decoder: coder, for: .problemJSON)
}

struct ProblemEndpoints: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        routes.get("problem", use: problem)
    }

    @Sendable
    func problem(req: Request) async throws -> ProblemDetails {
        ProblemDetails(type: "about:blank", title: "Not Found", status: 404, detail: "No such book")
    }
}
$ curl -si localhost:8080/problem
HTTP/1.1 200 OK
content-type: application/problem+json

{
  "detail" : "No such book",
  "status" : 404,
  "title" : "Not Found",
  "type" : "about:blank"
}

The pitfall that the example avoids: if you register Vapor’s own JSONEncoder for the new media type, the response still carries content-type: application/json, because that encoder writes its own header. The status of this response is 200 only because the route returns the value directly; wrap it in a Response to send the 404 itself. How problem bodies are produced for thrown errors belongs to the Error Handling page.

Content negotiation: HTML or JSON from one route

HTTP lets the client say what it can consume in the Accept header, with optional quality values (q, from 0 to 1, default 1). A browser typically asks for text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8; a script asks for application/json. Serving both from the same URL means reading that header and choosing a representation, and answering 406 Not Acceptable when you can produce none of what was asked. Vapor parses the header for you: req.headers.accept is an array of HTTPMediaTypePreference values (mediaType and q), and req.headers.accept.comparePreference(for:to:) compares two types (.orderedDescending means the first is preferred).

comparePreference only answers "which of these two named types is preferred". It does not rank the whole header, it does not take the order of the entries into account, and a */* entry in the header counts for both of the types being compared (media type equality treats a wildcard as equal to everything), so it cannot tell you that a browser prefers HTML. It also cannot tell you that neither type is acceptable. A small explicit ranking is clearer and handles wildcards and the 406 case:

import Vapor

struct BookSummary: Content {
    var id: Int
    var title: String
}

enum Representation {
    case html, json
}

extension Request {
    /// Rank the Accept header by q (ties keep header order) and return the first type we can produce.
    /// No Accept header, or a wildcard, means JSON; `nil` means we cannot satisfy the client.
    var preferredRepresentation: Representation? {
        let preferences = headers.accept
        if preferences.isEmpty {
            return .json
        }
        let ranked = preferences.enumerated().sorted { lhs, rhs in
            let lq = lhs.element.q ?? 1.0
            let rq = rhs.element.q ?? 1.0
            return lq != rq ? lq > rq : lhs.offset < rhs.offset
        }
        for (_, preference) in ranked where (preference.q ?? 1.0) > 0 {
            switch (preference.mediaType.type, preference.mediaType.subType) {
            case ("text", "html"):
                return .html
            case ("application", "json"), ("application", "*"), ("*", "*"):
                return .json
            default:
                continue
            }
        }
        return nil
    }
}

struct NegotiationEndpoints: RouteCollection {
    private let books = [BookSummary(id: 1, title: "Dune"), BookSummary(id: 2, title: "Emma")]

    func boot(routes: any RoutesBuilder) throws {
        routes.get("catalog", use: catalog)
    }

    @Sendable
    func catalog(req: Request) async throws -> Response {
        guard let representation = req.preferredRepresentation else {
            throw Abort(.notAcceptable, reason: "This resource is available as text/html or application/json")
        }
        switch representation {
        case .json:
            return try await books.encodeResponse(for: req)
        case .html:
            let items = books.map { "<li>\($0.title)</li>" }.joined()
            var headers = HTTPHeaders()
            headers.contentType = .html
            headers.add(name: .vary, value: "Accept")
            return Response(status: .ok, headers: headers, body: .init(string: "<ul>\(items)</ul>"))
        }
    }
}

Captured results:

Accept header Representation

text/html

HTML list

application/json

JSON array

text/html,application/json;q=0.9

HTML (higher q)

text/html;q=0.5,application/json;q=0.9

JSON (higher q)

*/* or no header

JSON (the default)

text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 (a browser)

HTML

image/png or text/html;q=0

406 with the reason from the Abort

A response whose body depends on a request header should say so: the HTML branch adds Vary: Accept, so shared caches keep the two representations apart. An alternative that needs no header at all is a format suffix or a separate route (/books.json, /api/books), which many applications prefer for debuggability. Negotiation on one URL is a good fit for the HTML-and-JSON applications where a browser and an API client use the same resources; it is overkill for a pure API.

sequenceDiagram participant C as Client participant R as Route handler C->>R: GET /catalog with Accept header R->>R: Rank Accept values by q alt text/html ranks first R-->>C: 200 text/html with Vary Accept else application/json or wildcard R-->>C: 200 application/json else nothing acceptable R-->>C: 406 Not Acceptable end

DTOs versus models

A Content struct is a wire format, and the wire format and the storage model are different things. The classic mistake, repeated in many tutorials, is to make the database model Content and return it straight from the handler. The model usually holds fields the client must never see (the password hash, internal flags, soft-delete markers) and fields the client must never set (the identifier, the role). Encoding the model hands the first group to every caller; decoding it into a model lets a client set the second group ("mass assignment").

The fix is two small structs per use case: one for what a client may send, one for what it may see, and an explicit mapping between them and the model.

import Vapor

/// An internal model: never returned to clients.
struct Account: Sendable {
    var id: UUID
    var email: String
    var passwordHash: String
    var isAdmin: Bool
}

/// The request DTO: only what a client may send.
struct RegisterRequest: Content {
    var email: String
    var password: String
}

/// The response DTO: only what a client may see.
struct AccountResponse: Content {
    var id: UUID
    var email: String

    init(_ account: Account) {
        id = account.id
        email = account.email
    }
}

struct AccountEndpoints: RouteCollection {
    func boot(routes: any RoutesBuilder) throws {
        routes.post("accounts", use: register)
    }

    @Sendable
    func register(req: Request) async throws -> AccountResponse {
        let input = try req.content.decode(RegisterRequest.self)
        // Placeholder: a real service hashes with req.password.async.hash(_:).
        let account = Account(
            id: UUID(uuidString: "00000000-0000-0000-0000-000000000001")!,
            email: input.email, passwordHash: "$placeholder$", isAdmin: false)
        return AccountResponse(account)
    }
}
$ curl -s -X POST localhost:8080/accounts -H 'Content-Type: application/json' \
    -d '{"email":"a@example.invalid","password":"placeholder-pw"}'
{"email":"a@example.invalid","id":"00000000-0000-0000-0000-000000000001"}

The Account is not Content, so the compiler stops anyone from returning it by accident, and AccountResponse lists exactly the fields that may leave the server. Advantages beyond secrecy: the API can evolve independently of the schema, the request DTO can omit server-assigned fields, and validation attaches to the request DTO. The same reasoning applies to Fluent models, which are often declared Content for convenience; the Fluent Models page shows the mapping. The wider question of how DTOs are versioned and shaped for a public API is on API Design, Versioning and DTOs, and sharing the same DTOs with an iOS client in a Swift package is on Full-Stack Swift with iOS Clients.

Two cautions. Make the response DTO an allow-list by construction (an initializer that copies named fields, as above) rather than a copy of the model with fields deleted, so a newly added model field is private until someone exposes it. And never put a password or password hash in any Content type that is logged: req.logger output and error reports can include decoded values.

Swift 6 concurrency notes

  • Content refines Sendable. A struct of Sendable properties (strings, numbers, UUID, Date, arrays and optionals of those, other Sendable structs) is implicitly Sendable and needs no annotation. A class DTO must be final with immutable stored properties, or you get the usual Swift 6 diagnostics; prefer structs.

  • File is Sendable, so a Content with a File property stays Sendable.

  • A route handler passed as a use: function reference must be @Sendable in Swift 6 mode (a method of a Sendable struct conforming to RouteCollection is marked @Sendable as in the examples); a closure passed inline is inferred.

  • ContentConfiguration.global is guarded by a lock, so mutating it is not a data race, but the value semantics make it a global that every request reads. Configure it in configure(_:) before the server accepts connections, and treat it as read-only afterwards.

  • ContentEncoder and ContentDecoder refine Sendable. A custom coder should be a struct of immutable values. The example builds a fresh JSONEncoder per call, because that avoids sharing a mutable reference type across threads; Vapor itself registers shared JSONEncoder instances, which is safe as long as nobody changes their properties after registration.

  • decode and encode are synchronous; they do not suspend, so decoding does not by itself make a handler need await.

Common errors and pitfalls

Symptom Status Cause and fix

Support for reading media type 'text/xml' has not been configured.

415

the Content-Type has no decoder; send JSON or a form, or register a decoder

Can't decode data without a content type

415

no Content-Type header; the client forgot it (curl -d adds a form type, -X POST alone adds none)

empty body with a JSON header

422

the container found no collected body; also what a streamed body produces

No such key 'author' at path ''.

400

a required property is missing from the JSON

No value found (expected type 'String') at path 'author'.

400

the same for a form or query, where the key is absent

Value was not of type 'Int' at path 'year'.

400

the JSON has the wrong type for the property

Data corrupted ... not valid JSON

400

malformed JSON text

Unable to decode ISO-8601 date.

400

the query decoder default reads dates as seconds since 1970, or the string is not full ISO-8601

Plaintext decoding does not support dictionaries.

400

a text/plain body decoded into a struct

JSON field names do not match (createdAt vs created_at)

200 with missing data, or 400

the key strategy is the default; add CodingKeys or configureSnakeCaseJSON()

the handler never sees the normalized value

none

the hooks only run for Content overloads; do not decode through Decodable

a returned String shows up as text/plain

200

String is Content with a plain-text default; return a Content struct for JSON

upload larger than 16 kB fails

413

the default collected-body limit; raise it with .collect(maxSize:) or stream

The 400 bodies above are the default JSON error shape, {"error":true,"reason":"..."}, produced by the error middleware; customizing that shape is covered by the Error Handling page. Decoder messages can echo parts of the input and the structure of your types; consider whether you want clients to see them in production.

Version notes and outdated material

  • Vapor 3 and the books written for it returned EventLoopFuture values from req.content.decode and req.content.get, so code was req.content.decode(User.self).flatMap { ... }. In Vapor 4 these are synchronous and throwing. Rewrite .decode(User.self).map chains as let user = try req.content.decode(User.self).

  • Vapor 3 registered coders through a ContentConfig service in configure(_:_:_:). Vapor 4 uses the static ContentConfiguration.global shown above.

  • Older tutorials make every model Content and return it directly, including models that carry a password hash; use the DTO pattern above, whatever an older example does.

  • Content negotiation between HTML and JSON on the same routes, as used in server-rendered Vapor applications, still works as above; prefer the explicit ranking over comparePreference for browser Accept headers.

  • Vapor 5 (5.0.0-beta.4 at the time of writing) is in beta, not released, and may change these APIs. This page is written for Vapor 4.122.x; check the Vapor 5 release notes before moving (What’s New, Upgrading and Vapor 5).