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.
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 |
|---|---|
|
a |
|
one registered route: |
|
a protocol with one requirement, |
Source: Vapor API documentation
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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
any |
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 }
}
Source: Vapor docs: Routing
$ 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
Source: Vapor docs: Routing
Things worth knowing about methods:
-
HEAD is answered from GET. If a request is
HEADand aHEADroute exists for the path it runs, otherwiseDefaultResponderforwards it to theGETroute.curl -I localhost:8080/bookstherefore returns the headers of theGEThandler without you registering anything. -
A known path with the wrong method is a 404, not a 405. A
POST /pingagainst theHEAD /pingroute above returns404 Not Foundwith noAllowheader, 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 returnedRoutelets you attach metadata (see below). -
The closure is
@Sendable. Capturing a non-Sendablevalue, or mutating a capturedvar, 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) ofSendabletypes, or read services fromreq.applicationat request time. -
HTTPMethodis the NIO type..GET,.POST,.PUT,.PATCH,.DELETE,.OPTIONS,.HEADand others are static values; the router stores the method’srawValueas 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 |
|---|---|---|
|
|
exactly that segment (case-sensitive unless configured otherwise) |
|
|
any one segment; the value is stored under the name |
|
|
any one segment; the value is discarded |
|
|
one or more remaining segments; the matched segments are stored for |
Source: Vapor docs: Routing
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" }
}
Source: Vapor docs: Routing, parameters
$ 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}
Source: Vapor docs: Routing, parameters
Reading parameters
req.parameters is a routing-kit Parameters value. The members you use:
| Member | Behaviour |
|---|---|
|
the raw segment, or |
|
converts with |
|
Vapor extension; throws |
|
same, and throws |
|
the segments matched by |
|
the names of all captured parameters |
Source: Vapor API documentation
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:
-
Constants win. A constant child is tried first at every level, so
/pick/specialbeats/pick/:name. -
Then the wildcard slot, which is a single slot per level shared by
:parameterand*. -
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
:nameand a*at the same position are the same node. In a test withapp.get("pick", ":name")followed byapp.get("pick", "*"), the later registration won for/pick/otherand 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,:bookIDthroughout, 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" }
}
Source: vapor/routing-kit: TrieRouter
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)
}
Source: Vapor docs: Routing
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 |
|---|---|
|
returns a builder whose routes get the path prefix prepended |
|
the same, scoped to a closure (the closure may throw; |
|
returns a builder whose routes run through the middleware ( |
|
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), andapp.middleware.usefor 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 withappand 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))
}
Source: Vapor docs: Controllers
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 up to |
|
collect up to a custom size; the argument is a |
|
do not collect; |
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)" }
}
}
Source: Vapor API documentation
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 |
|---|---|---|
|
303 See Other |
after a |
|
307 Temporary Redirect |
temporary move that keeps the method and body |
|
301 Moved Permanently |
permanent move of a |
|
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
@Sendableand run on the cooperative pool when they areasync; they may hop executors at everyawait. Do not keep mutable state in a localvarcaptured by the closure. Put shared state in anactoror in aSendableservice stored on the application (see the application-lifecycle page). -
RequestisSendablein 4.122.2 (its mutable state is protected by locks), so passingreqto an actor or aTaskcompiles. 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,RouteandParametersareSendable;Routeprotects its properties with a lock, so mutatinguserInfoafter registration is safe, but the table is still read once at startup.
Common errors and pitfalls
| Symptom | Cause and fix |
|---|---|
|
the method differs, the case differs ( |
crash at launch: "same prefix but different parameter names" |
two routes use different |
|
the catch-all needs at least one segment; register |
|
|
|
|
|
the collected body exceeded |
|
nothing was collected with |
a string |
a single argument is one constant segment; pass |
compiler warning: capture of non- |
a data race the Swift 6 language mode reports only as a warning because the route methods are |
a |
they share one wildcard slot at that position; keep only one |
Source: Vapor docs: Routing
Where the books and old tutorials are outdated
-
Vapor 3 registered routes on a
Router:router.get("books", Int.parameter) { req in ... }, read them withtry req.parameters.next(Int.self), and returnedFuture<T>. In Vapor 4 the typedInt.parameterandparameters.nextare gone: use":id"andreq.parameters.get("id", as: Int.self)orrequire, and theroutes(_ app: Application)function instead ofroutes(_ router: Router). Calls no longerthrowwhen registering. -
Vapor 2 used resource controllers (
drop.resource("books", BookController())) anddrop.get("books", ":id")withrequest.parameters["id"]. Neither exists; useRouteCollection(the Controllers and Route Collections page) andapp.get/app.on. -
Early Vapor 4 books and tutorials show handlers returning
EventLoopFuture<...>andbody: .collect(maxSize: 1_000_000)with an integer;maxSizeis aByteCounttoday, which accepts strings such as"1mb"as well as integer literals.req.redirect(to:type:)withRedirectTypewas renamed toredirect(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 callingapp.getby 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.