Routing

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, the official template declares 6.3) with routing-kit 4.9.3, checked on 2026-10-17. Every Swift listing on this page was compiled in the Swift 6 language mode with zero warnings, and the curl output and the routes table were captured from that build running locally. Vapor 5 is at 5.0.0-beta.4 and is not released; the differences that matter are collected at the end. All examples use async/await; the EventLoopFuture forms of the same methods still exist but belong to the legacy section of the Swift Concurrency in Vapor page.

Routing is how a Vapor application decides which closure answers a request. It looks only at the HTTP method and the path, never at the query string, the headers or the body. The running project is the Bookshelf service created on the Getting Started page.

Where routing sits in the request

A request travels through SwiftNIO (connection and HTTP parsing), the middleware chain registered with app.middleware, the router, the route’s own middleware (from grouped(_:)), the handler, and finally the response encoding. Vapor’s DefaultResponder is the link between the application-level middleware and the router: it builds one TrieRouter from app.routes.all when the application starts, and for every request it looks up the route and runs app.middleware chained to the route’s responder. If no route matches, the same application middleware still wraps a not-found responder, so the error middleware renders the 404 and logging or CORS middleware still see the request.

flowchart LR A["HTTP request GET /books/42"] --> B["app.middleware: error, CORS"] B --> C["TrieRouter lookup
method + path"] C -->|match| D["Group middleware from grouped"] C -->|no match| E["404 Not Found via the error middleware"] D --> F["Body collection: collect or stream"] F --> G["Handler closure"] G --> H["ResponseEncodable encodeResponse"] H --> I["HTTP response"]

Two consequences follow from this order. The route is chosen before any middleware runs, so req.route is already set inside application-level middleware, and body collection happens inside the route, so the size limit is a property of the route, not of the server. Both are used in the sections below.

The route table: Routes, Route and RoutesBuilder

Three types make up the public API:

Type Role

Routes

a final class (Sendable) holding every registered route in all, plus the settings defaultMaxBodySize and caseInsensitive; the application owns one, app.routes

Route

one registered route: method, path ([PathComponent]), responder, requestType, responseType, userInfo and the description(_:) helper; also Sendable, with its mutable properties protected by a lock

RoutesBuilder

a protocol with one requirement, add(_ route: Route); Application and Routes conform to it, and so do the internal group types returned by grouped, which is why app.get and group.get are the same API

All the verb methods (get, post, put, patch, delete, on) are extensions of RoutesBuilder that wrap your closure in a BasicResponder, create a Route, call add and return the Route. Registration happens once, normally from routes(_:) called by configure(_:). The table is read when the server starts: routes added after app.execute() started the server are not seen by the router.

HTTP methods

The shortcut methods map one to one to HTTP methods, and on accepts any HTTPMethod:

Method Registers

app.get(...)

GET

app.post(...)

POST

app.put(...)

PUT

app.patch(...)

PATCH

app.delete(...)

DELETE

app.on(_ method: HTTPMethod, ...)

any HTTPMethod, such as .OPTIONS or .HEAD

Each exists in two overloads, one taking variadic PathComponent... and one taking a [PathComponent], and each takes the handler as the trailing closure use:. The handler returns anything that is ResponseEncodable: a String, a Content struct, an HTTPStatus, a Response, an array of content, or Void-like statuses. The handler may be synchronous, async, or async throws; throwing an Abort produces the matching HTTP error (the Error Handling page covers the details).

import Vapor

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

struct NewBook: Content {
    var title: String
}

func routes(_ app: Application) throws {
    app.get("books") { req async -> [Book] in
        [Book(id: 1, title: "Dune")]
    }
    app.post("books") { req async throws -> Response in
        let input = try req.content.decode(NewBook.self)
        return try await Book(id: 2, title: input.title)
            .encodeResponse(status: .created, for: req)
    }
    app.put("books", ":id") { req async throws -> Book in
        let id = try req.parameters.require("id", as: Int.self)
        return Book(id: id, title: "Replaced")
    }
    app.patch("books", ":id") { req async -> HTTPStatus in .noContent }
    app.delete("books", ":id") { req async -> HTTPStatus in .noContent }
    app.on(.OPTIONS, "books") { req async -> HTTPStatus in .noContent }
    app.on(.HEAD, "ping") { req async -> HTTPStatus in .ok }
}
$ curl -i localhost:8080/books
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 25

[{"title":"Dune","id":1}]

$ curl -i -X POST -H 'content-type: application/json' -d '{"title":"Emma"}' localhost:8080/books
HTTP/1.1 201 Created
content-type: application/json; charset=utf-8
content-length: 23

{"title":"Emma","id":2}

$ curl -i -X DELETE localhost:8080/books/3
HTTP/1.1 204 No Content

Things worth knowing about methods:

  • HEAD is answered from GET. If a request is HEAD and a HEAD route exists for the path it runs, otherwise DefaultResponder forwards it to the GET route. curl -I localhost:8080/books therefore returns the headers of the GET handler without you registering anything.

  • A known path with the wrong method is a 404, not a 405. A POST /ping against the HEAD /ping route above returns 404 Not Found with no Allow header, because the method is part of the lookup key. Frameworks such as axum answer 405; if you need that, add a catch-all route or middleware.

  • The shortcut methods are @discardableResult; keeping the returned Route lets you attach metadata (see below).

  • The closure is @Sendable. Capturing a non-Sendable value, or mutating a captured var, in a route closure is a data race, because handlers run concurrently. The route methods are @preconcurrency, so the Swift 6 language mode reports it as a warning, not an error, and the build still succeeds. Capture values (let) of Sendable types, or read services from req.application at request time.

  • HTTPMethod is the NIO type. .GET, .POST, .PUT, .PATCH, .DELETE, .OPTIONS, .HEAD and others are static values; the router stores the method’s rawValue as the first path segment of the trie, which is why a mismatch cannot fall through to another method’s route.

Path components

A path is a list of PathComponent, an enum from routing-kit with four cases:

Written as Case Matches

"books"

.constant("books")

exactly that segment (case-sensitive unless configured otherwise)

":id"

.parameter("id")

any one segment; the value is stored under the name id

"*"

.anything

any one segment; the value is discarded

"**"

.catchall

one or more remaining segments; the matched segments are stored for getCatchall()

PathComponent is ExpressibleByStringInterpolation, so string literals become the right case: a string starting with : is a parameter, exactly * is anything, exactly ** is the catch-all and everything else is a constant. The literal "books/:id" is not split: it stays a single constant containing a slash, which never matches a real request. Pass each segment as a separate argument, or split a string yourself with the pathComponents property routing-kit adds to String.

import Vapor

enum Shelf: String, LosslessStringConvertible, Sendable {
    case fiction, science

    init?(_ description: String) { self.init(rawValue: description) }
    var description: String { rawValue }
}

func pathRoutes(_ app: Application) {
    app.get("shelves", ":shelf") { req async throws -> String in
        guard let shelf = req.parameters.get("shelf", as: Shelf.self) else {
            throw Abort(.notFound, reason: "Unknown shelf")
        }
        return "shelf \(shelf)"
    }
    app.get("authors", ":authorID", "books", ":bookID") { req async throws -> String in
        let author = try req.parameters.require("authorID", as: UUID.self)
        let book = req.parameters.get("bookID") ?? "?"
        return "\(author) \(book)"
    }
    app.get("files", "*", "meta") { req async -> String in "meta" }
    app.get("assets", "**") { req async -> String in
        req.parameters.getCatchall().joined(separator: "|")
    }
    app.get(["legacy", "v1", "books"]) { req async -> String in "legacy" }
    app.get("a/b/c".pathComponents) { req async -> String in "abc" }
}
$ curl -i localhost:8080/shelves/fiction
shelf fiction

$ curl -i localhost:8080/shelves/poetry
HTTP/1.1 404 Not Found
{"reason":"Unknown shelf","error":true}

$ curl -i localhost:8080/authors/not-uuid/books/1
HTTP/1.1 422 Unprocessable Entity
{"reason":"The parameter value could not be converted to the required type","error":true}

$ curl localhost:8080/assets/css/site/app.css
css|site|app.css

$ curl -i localhost:8080/assets
HTTP/1.1 404 Not Found
{"reason":"Not Found","error":true}

Reading parameters

req.parameters is a routing-kit Parameters value. The members you use:

Member Behaviour

get(_ name: String) -> String?

the raw segment, or nil when the name is not in the route

get<T: LosslessStringConvertible>(_ name: String, as: T.Type) -> T?

converts with T.init(_: String); nil when the parameter is missing or the conversion fails

require(_ name: String) throws -> String

Vapor extension; throws Abort(.internalServerError) when the name is not a parameter of the matched route (a programming error)

require<T>(_ name: String, as: T.Type) throws -> T

same, and throws Abort(.unprocessableEntity) when the value cannot be converted, giving the client a 422 instead of a 500

getCatchall() -> [String]

the segments matched by **, already split; empty when the route has no catch-all

allNames: Set<String>

the names of all captured parameters

Prefer require(_:as:) for ids: it fails with a client error and a message, and you avoid an if let ladder. Use get(_:as:) when "not convertible" has a meaning of its own, as with Shelf above (a 404 rather than a 422).

Any LosslessStringConvertible can be a parameter: Int, Double, Bool, String, UUID (Vapor declares the conformance for Foundation.UUID itself), and your own types. A String backed enum needs an init?(_:) and a description, as Shelf does; RawRepresentable alone is not enough. With UUID ids, req.parameters.require("bookID", as: UUID.self) is the typical first line of a handler.

A parameter never matches an empty segment and never contains a /.

Wildcards, the catch-all and precedence

"*" matches exactly one segment and "**" matches one or more, so /assets/** does not match /assets itself (register it separately if you want that). When several routes could match, the trie resolves them with fixed rules:

  1. Constants win. A constant child is tried first at every level, so /pick/special beats /pick/:name.

  2. Then the wildcard slot, which is a single slot per level shared by :parameter and *.

  3. Then the catch-all, which has the lowest precedence and is remembered while the search continues: if the more specific branch hits a dead end later in the path, the router falls back to the nearest catch-all.

The shared wildcard slot has two consequences that surprise people:

  • A :name and a * at the same position are the same node. In a test with app.get("pick", ":name") followed by app.get("pick", "*"), the later registration won for /pick/other and the handler of * ran. Do not register both at one position.

  • Two routes with the same prefix must use the same parameter name at the same position. "books", ":id" and "books", ":bookID", "reviews" crash the process at startup with a failed precondition ("It is not possible to have two routes with the same prefix but different parameter names"). Pick one name per position, :bookID throughout, for example.

import Vapor

func precedenceRoutes(_ app: Application) {
    app.get("pick", "special") { req async -> String in "constant" }
    app.get("pick", ":name") { req async -> String in "parameter" }
    app.get("pick", "**") { req async -> String in "catchall" }
}

With these three routes /pick/special answers constant, /pick/other answers parameter and /pick/a/b answers catchall. An empty segment is ignored: /books/ and /books are the same request, and empty constants in a route path are removed when the router is built.

Case sensitivity

Constants are case-sensitive by default (/Books is a 404 when you registered books). Set app.routes.caseInsensitive = true in configure(_:), before the server starts, to make constants match regardless of case. Parameter values keep the case the client sent.

import Vapor

func configure(_ app: Application) async throws {
    app.routes.caseInsensitive = true
    app.routes.defaultMaxBodySize = "512kb"
    try routes(app)
}

With this configuration curl localhost:8080/BOOKS returns the same list as /books. Case-insensitivity applies to the router lookup only; it does not redirect to a canonical spelling.

Route groups

Grouping avoids repeating path prefixes and middleware. There are two kinds, both defined as extensions of RoutesBuilder and both available in two shapes: grouped(_:) returns a new builder, group(_:configure:) passes it to a closure.

Call Effect

grouped("api", "v1")

returns a builder whose routes get the path prefix prepended

group("api", "v2") { v2 in ... }

the same, scoped to a closure (the closure may throw; group is rethrows)

grouped(AuditTrail())

returns a builder whose routes run through the middleware (Middleware or AsyncMiddleware values, variadic or an array)

group(AuditTrail()) { r in ... }

the same, scoped to a closure

import Vapor

struct AuditTrail: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        request.logger.info("audit \(request.method.rawValue) \(request.url.path)")
        let response = try await next.respond(to: request)
        response.headers.replaceOrAdd(name: "X-Audited", value: "yes")
        return response
    }
}

func groupRoutes(_ app: Application) {
    let api = app.grouped("api", "v1")
    api.get("health") { req async -> String in "ok" }

    app.group("api", "v2") { v2 in
        v2.get("health") { req async -> String in "ok v2" }
    }

    let audited = app.grouped(AuditTrail())
    audited.get("audited") { req async -> String in "audited" }

    let admin = app.grouped("admin").grouped(AuditTrail())
    admin.get("stats") { req async -> String in "stats" }
}
$ curl -i localhost:8080/admin/stats
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 5
X-Audited: yes

stats

How groups work internally explains their behaviour. A path group wraps its parent: when a route is added, the group sets route.path = prefix + route.path and forwards to the parent. A middleware group does route.responder = middleware.makeResponder(chainingTo: route.responder). Groups nest, so each layer wraps the previous one, and middleware order is the order of the grouped calls: the middleware of the outer group runs first. Notes:

  • grouped([]) with an empty middleware array returns the receiver itself.

  • Group middleware runs after the route is matched and inside app.middleware. Use it for things that apply only to a subset of routes (authentication of /admin), and app.middleware.use for things that apply to every request, 404s included (CORS, error rendering, file serving). The Middleware page covers both.

  • A group is a value you can pass around. Functions that register routes should take any RoutesBuilder, so they work with app and with any group:

import Vapor

func registerBookRoutes(on routes: any RoutesBuilder) {
    let books = routes.grouped("books")
    books.get { req async -> String in "list" }
    books.get(":bookID") { req async throws -> String in
        let id = try req.parameters.require("bookID", as: Int.self)
        return "book \(id)"
    }
}

extension RoutesBuilder {
    func versioned(_ version: Int) -> any RoutesBuilder {
        grouped("api", "v\(version)")
    }
}

func versionedRoutes(_ app: Application) {
    registerBookRoutes(on: app.versioned(3))
}

GET /api/v3/books/7 answers book 7. With the any keyword the code builds under the ExistentialAny upcoming feature that the official template enables. A method with no path components, as in books.get { ... }, registers the group’s own path.

Request bodies: collecting versus streaming

By default Vapor reads the whole request body into memory before it calls your handler, so req.body.data and req.content.decode just work. Because that is a denial-of-service risk, the amount collected is capped. The strategy is a per-route argument, body: HTTPBodyStreamStrategy, available on on(_:_:body:use:):

Strategy Meaning

.collect

collect up to app.routes.defaultMaxBodySize (the default argument)

.collect(maxSize: "1kb")

collect up to a custom size; the argument is a ByteCount, which accepts literals such as "1kb", "16mb" or an integer of bytes

.stream

do not collect; req.body is an AsyncSequence of ByteBuffer chunks you consume yourself

Routes.defaultMaxBodySize starts at 16 kilobytes ("16kb"), small on purpose; a JSON API usually raises it in configure(_:), as the configure example above does with "512kb". When a collected body exceeds the limit the request fails with Abort(.payloadTooLarge), answered as 413 Payload Too Large, and the handler never runs.

import Vapor

func uploadRoutes(_ app: Application) {
    app.on(.POST, "upload", body: .collect(maxSize: "1kb")) { req async throws -> String in
        "got \(req.body.data?.readableBytes ?? 0)"
    }
    app.on(.POST, "stream", body: .stream) { req async throws -> String in
        var total = 0
        for try await chunk in req.body {
            total += chunk.readableBytes
        }
        return "streamed \(total)"
    }
}
$ curl -i -X POST --data-binary @2000-bytes.txt localhost:8080/upload
HTTP/1.1 413 Payload Too Large
{"reason":"Payload Too Large","error":true}

$ curl -X POST --data-binary @500-bytes.txt localhost:8080/upload
got 500

$ curl -X POST --data-binary @5000-bytes.txt localhost:8080/stream
streamed 5000

(2000-bytes.txt and the other files are plain files of that many bytes.) With .stream, req.body.data is nil and req.content.decode cannot work, since nothing has been collected; read the chunks with for try await and process or write them as they arrive (to a file, to object storage, to a hash), so memory use stays constant. See File Uploads and Streaming for complete streaming upload and download handlers and Requests and Responses for reading the body, query and headers. Multipart uploads and req.fileio use the same mechanism, covered on the file-uploads page.

Two pitfalls: raising defaultMaxBodySize raises the memory an attacker can make you allocate per request, so prefer a bigger maxSize on the specific upload route; and a reverse proxy in front (nginx’s client_max_body_size) has its own limit and returns its own 413 before Vapor sees the request.

Route metadata and introspection

Route carries a userInfo dictionary ([AnySendableHashable: Sendable]) and a description(_:) helper that stores a string under the "description" key. Both exist for tooling: OpenAPI generators and the routes command read them, and your own middleware can too, through req.route, which the router sets once a route matched.

import Vapor

struct RouteTagMiddleware: AsyncMiddleware {
    func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response {
        let response = try await next.respond(to: request)
        if let tag = request.route?.userInfo["tag"] as? String {
            response.headers.replaceOrAdd(name: "X-Route-Tag", value: tag)
        }
        return response
    }
}

func metadataRoutes(_ app: Application) {
    app.middleware.use(RouteTagMiddleware())

    app.get("documented") { req async -> String in "doc" }
        .description("A documented route")

    let tagged = app.get("tagged") { req async -> String in "tagged" }
    tagged.userInfo["tag"] = "public"

    app.get("debug", "routes") { req async -> [String] in
        req.application.routes.all.map { "\($0.method.rawValue) /\($0.path.string)" }
    }
}

curl -i localhost:8080/tagged returns the header X-Route-Tag: public. The middleware reads req.route after calling next only to keep the example short; the route is set before the chain starts, so reading it before next works too. Routes.all is [Route] in registration order, and path.string (a routing-kit extension on sequences of PathComponent) renders books/:id. Route.requestType and responseType hold the Swift types for tools; the verb methods set them to Request and the closure’s return type.

The routes command prints the same table without starting the server:

$ swift run App routes
+---------+----------------------------------+--------------------+
| GET     | /books                           |                    |
+---------+----------------------------------+--------------------+
| POST    | /books                           |                    |
+---------+----------------------------------+--------------------+
| PUT     | /books/:id                       |                    |
+---------+----------------------------------+--------------------+
| GET     | /files/*/meta                    |                    |
+---------+----------------------------------+--------------------+
| GET     | /assets/**                       |                    |
+---------+----------------------------------+--------------------+

The third column shows the route description when one was set. Use the command in CI to detect accidental route changes. Inside a handler, req.route is the matched Route (nil only for the not-found responder), and req.url.path is the concrete path.

Redirects

req.redirect(to:redirectType:) builds a Response with the status of the chosen Redirect and a Location header. Abort.redirect(to:redirectType:) does the same as a throwable error, useful deep inside helper code.

Value Status Use

.normal (default)

303 See Other

after a POST: the browser follows with a GET

.temporary

307 Temporary Redirect

temporary move that keeps the method and body

.permanent

301 Moved Permanently

permanent move of a GET resource; browsers cache it

.permanentPost

308 Permanent Redirect

permanent move that keeps the method and body

import Vapor

func redirectRoutes(_ app: Application) {
    app.get("old") { req async -> Response in
        req.redirect(to: "/books", redirectType: .permanent)
    }
    app.post("login") { req async -> Response in
        req.redirect(to: "/books")
    }
    app.get("go") { req async throws -> String in
        throw Abort.redirect(to: "/books", redirectType: .temporary)
    }
}
$ curl -i localhost:8080/old
HTTP/1.1 301 Moved Permanently
content-length: 0
location: /books

$ curl -i -X POST localhost:8080/login
HTTP/1.1 303 See Other
location: /books

Never build the target from unvalidated user input (an open-redirect vulnerability). The older req.redirect(to:type:) with RedirectType still compiles in 4.122 but is deprecated in favour of Redirect. A thrown Abort.redirect goes through the error middleware, which keeps the Location header and adds a JSON body ({"reason":"Temporary Redirect","error":true}), harmless for browsers.

Concurrency and isolation notes

  • Route closures are @Sendable and run on the cooperative pool when they are async; they may hop executors at every await. Do not keep mutable state in a local var captured by the closure. Put shared state in an actor or in a Sendable service stored on the application (see the application-lifecycle page).

  • Request is Sendable in 4.122.2 (its mutable state is protected by locks), so passing req to an actor or a Task compiles. A task that outlives the handler should still take only the pieces it needs (let id = ...), so it does not keep the whole request and its collected body alive.

  • Routes, Route and Parameters are Sendable; Route protects its properties with a lock, so mutating userInfo after registration is safe, but the table is still read once at startup.

Common errors and pitfalls

Symptom Cause and fix

404 for a path that "should" exist

the method differs, the case differs (caseInsensitive is off), or the route was added after the server started

crash at launch: "same prefix but different parameter names"

two routes use different :names for the same position; unify them

/assets/** does not match /assets

the catch-all needs at least one segment; register assets separately

422 when reading an id

require(_:as:) could not convert the segment (/books/abc with Int); this is intended, or use get and choose the status yourself

500 "The parameter provided does not exist"

require("id") for a name the route does not declare; fix the spelling (:bookID versus "id")

413 Payload Too Large

the collected body exceeded defaultMaxBodySize or the route’s maxSize; raise it for that route or stream

req.content.decode fails on a streaming route

nothing was collected with .stream; read req.body chunks instead

a string "books/:id" never matches

a single argument is one constant segment; pass "books", ":id" or "books/:id".pathComponents

compiler warning: capture of non-Sendable type in a @Sendable closure

a data race the Swift 6 language mode reports only as a warning because the route methods are @preconcurrency; make the captured type Sendable, or read it from req.application inside the closure

a :name route is shadowed by a * route (or the reverse)

they share one wildcard slot at that position; keep only one

Where the books and old tutorials are outdated

  • Vapor 3 registered routes on a Router: router.get("books", Int.parameter) { req in ... }, read them with try req.parameters.next(Int.self), and returned Future<T>. In Vapor 4 the typed Int.parameter and parameters.next are gone: use ":id" and req.parameters.get("id", as: Int.self) or require, and the routes(_ app: Application) function instead of routes(_ router: Router). Calls no longer throw when registering.

  • Vapor 2 used resource controllers (drop.resource("books", BookController())) and drop.get("books", ":id") with request.parameters["id"]. Neither exists; use RouteCollection (the Controllers and Route Collections page) and app.get/app.on.

  • Early Vapor 4 books and tutorials show handlers returning EventLoopFuture<...> and body: .collect(maxSize: 1_000_000) with an integer; maxSize is a ByteCount today, which accepts strings such as "1mb" as well as integer literals. req.redirect(to:type:) with RedirectType was renamed to redirect(to:redirectType:), and the old form is deprecated.

  • Vapor 5 preview. Routing keeps the same concepts (app.get, grouped, PathComponent, Parameters) on top of structured concurrency, and the Vapor 5 branch previews macro-based routing, where annotating a type or function generates the route registration instead of calling app.get by hand. Treat it as a beta preview of 5.0.0-beta.4: the macro surface can still change, so the examples here use the stable Vapor 4 API.