Getting Started
|
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; the official
template declares swift-tools-version:6.3, Vapor 4.122.2 itself needs tools version 6.0), with the Vapor Toolbox
20.0.2 and swift-nio 2.104.0, checked on 2026-10-17. It creates the bookshelf project that the rest of this section
extends. Every Swift listing on this page was compiled with Swift 6.4 in the Swift 6 language mode with zero warnings,
and the curl and command output was captured from that build running on macOS. Vapor 5 is at 5.0.0-beta.4 and is not
released yet; where it matters, differences are noted. If you have not installed Swift and the Toolbox yet, do that
first (the Installation and Toolchain page covers macOS, Linux,
Swiftly and the editors; the Introduction & Architecture page
explains what Vapor is made of). Swift itself is covered by the Swift
Reference, and SwiftPM by Swift Package Manager.
The Toolbox 20 only creates projects (vapor new); building and running use swift build and swift run. The
Toolbox prompts below are therefore quoted from the official documentation and the Toolbox README, while the build,
curl and command output comes from the official vapor/template without Fluent and Leaf (what you get when you
answer "no" twice), plus the routes of this page.
Where it runs in the request
In the request lifecycle (SwiftNIO accepts the connection and parses HTTP, Vapor’s responder chain runs the middleware,
the router matches a route, the handler runs, and the returned value is encoded into a response), this page touches the
essential steps once. swift run App serve starts the HTTP server, the Router matches GET /hello or GET
/books/:id, the closure handler runs, and its return value (a String or a Content struct) is encoded as a
text/plain or application/json response. Only Vapor’s default middleware (the route logger that logs each request,
then the error middleware that turns an Abort into a JSON body) is involved. Custom middleware
(Middleware), Content validation (Validation)
and databases (Fluent Overview and Database Drivers) arrive on
later pages.
Creating the project with the Toolbox
The Vapor Toolbox is a command-line tool that clones the vapor/template repository,
asks the questions the template declares in its manifest.yml, renders the files and (by default) initialises a Git
repository. Creating the section’s running project:
$ vapor new bookshelf
Source: vapor/toolbox: README
The Toolbox README says the tool "asks for the required details". The template’s manifest.yml defines these
questions:
| Question | Answers | Effect on the generated project |
|---|---|---|
Would you like to use Fluent (ORM)? |
yes or no |
adds the Fluent package and |
Which database would you like to use? (only after yes) |
Postgres (recommended), MySQL or SQLite |
adds the matching Fluent driver package and its configuration in |
Would you like to use Leaf (templating)? |
yes or no |
adds the Leaf package and |
Source: vapor/template: manifest.yml
For Bookshelf the pages of this section start without Fluent and Leaf and add them on the pages that need them, so the example below answers "no" to both questions. The Toolbox’s flags, from its README:
| Flag | Meaning |
|---|---|
|
answer "no" to every question; the quickest way to a plain project (the official guide uses it) |
|
create the project in another folder |
|
do not create a Git repository |
|
create the repository but skip the initial commit |
|
use another Git repository as the template instead of |
|
branch of the template to use; defaults to |
|
YAML or JSON manifest to use; by default the Toolbox looks for |
|
lists the commands; with |
new is the default subcommand, so vapor bookshelf -n is the same as vapor new bookshelf -n. The README does not
document --fluent or --leaf flags; those names only appear as variables inside a template manifest, which is why a
custom template can define its own flags.
$ vapor new bookshelf -n
$ cd bookshelf
$ vapor new bookshelf-api --template https://github.com/vapor/template --branch main --no-git
Source: Vapor docs: Hello, world
The second command is only an illustration of the long options; --template https://github.com/vapor/template is the
default and --branch main too. Replace them with your own company template repository to share a project skeleton.
If you cannot or do not want to install the Toolbox, copy the files listed in
What the template generates by hand, or clone the template and edit it; the Toolbox
adds nothing that swift build needs.
What the template generates
The current template names the executable target, and its Sources/ and Tests/ folders, after the project:
manifest.yml and Package.swift use {{name}}, so vapor new bookshelf creates a bookshelf target and you run
it with swift run bookshelf serve. This section names the target App by convention (the name the template repository
itself and the Vapor docs use), so rename it in Package.swift, the folder names and the Dockerfile, or read App
as your project name in every command. Without Fluent and Leaf, and with the target named App, the project contains:
bookshelf/
Package.swift
Dockerfile
docker-compose.yml
Public/
Sources/App/
configure.swift
entrypoint.swift
routes.swift
Controllers/
Tests/AppTests/
AppTests.swift
Source: vapor/template: manifest.yml
With Fluent the Toolbox adds Models/Todo.swift, DTOs/TodoDTO.swift, Migrations/CreateTodo.swift and
Controllers/TodoController.swift; with Leaf it adds Resources/Views/index.leaf. The folder structure and
Package.swift are explained on the Project Structure and Swift
Package Manager page. The generated Dockerfile and docker-compose.yml are explained on the
Deployment with Docker page. The three Swift files you meet first are:
// Sources/App/routes.swift, as generated
import Vapor
func routes(_ app: Application) throws {
app.get { req async in
"It works!"
}
app.get("hello") { req async -> String in
"Hello, world!"
}
}
Source: vapor/template: routes.swift
// Sources/App/configure.swift, as generated (no Fluent, no Leaf)
import Vapor
func configure(_ app: Application) async throws {
try routes(app)
}
Source: vapor/template: configure.swift
entrypoint.swift contains the @main enum Entrypoint that detects the environment, bootstraps logging, creates the
Application with Application.make(_:), runs configure(_:) and app.execute(), and shuts the application down;
its anatomy is the subject of the Application Lifecycle and
the Entrypoint page. Two details matter now. First, execute() parses the command-line arguments and runs the command
they select, which is why swift run App serve and swift run App routes do different things with the same executable.
Second, when you give no command, swift run runs the default command, serve.
Building and running
From the project folder:
$ swift build
Building for debugging...
...
Build complete! (146,11 s)
$ swift run App serve
[ NOTICE ] Server started on http://127.0.0.1:8080
Source: Vapor docs: Hello, world
The first build clones and compiles Vapor, SwiftNIO and around 130 other dependencies (about two and a half minutes on
the machine used here, including a cold dependency cache); incremental builds take seconds. swift run App serve
builds if needed and starts the server. Three things to know about the output:
-
The Vapor 4 hello-world guide prints
[ INFO ] Server starting on http://127.0.0.1:8080. With Vapor 4.122.2 the default log level shows[ NOTICE ] Server started on ...once the socket is bound (the "starting" line is logged atdebug, see the--log debugrun below). Do not write tests that parse these lines. -
The server binds
127.0.0.1by default, so it is reachable only from the same machine. In a container or on a VM you must bind0.0.0.0(see the options below). -
The
Appinswift run Appis the executable target’s name inPackage.swift(Appby this section’s convention, the project name in a freshvapor newproject).swift runwithout a name works when the package has a single executable.
In Xcode, open the folder (or Package.swift), choose the App scheme and a Mac destination, and press Run. To pass a
command (for example serve) use "Arguments Passed On Launch" of the scheme; to make relative paths such as Public/
resolve, set the scheme’s custom working directory to the project folder (see the
Installation and Toolchain page for details).
Now call the two generated routes:
$ curl -i localhost:8080/
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 9
connection: keep-alive
date: Sat, 17 Oct 2026 11:19:32 GMT
It works!
$ curl -i localhost:8080/hello
HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 13
connection: keep-alive
date: Sat, 17 Oct 2026 11:19:32 GMT
Hello, world!
Source: Vapor docs: Hello, world
A handler returning a String produces text/plain; charset=utf-8: String conforms to ResponseEncodable. The route
app.get { ... } with no path components is the root path /. Unmatched requests are answered by Vapor, not by your
code, in the same JSON error shape you will use for your own failures:
$ curl -i localhost:8080/nope
HTTP/1.1 404 Not Found
content-type: application/json; charset=utf-8
content-length: 35
connection: keep-alive
date: Sat, 17 Oct 2026 11:19:32 GMT
{"error":true,"reason":"Not Found"}
$ curl -i -X POST localhost:8080/hello
HTTP/1.1 404 Not Found
content-type: application/json; charset=utf-8
content-length: 35
...
{"error":true,"reason":"Not Found"}
Source: Vapor docs: Errors
Unlike axum, Vapor answers a request with a known path but a method that has no route with 404 Not Found, not
405 Method Not Allowed, and sends no Allow header. The server also logs one line per request at info level:
[ INFO ] GET /hello [request-id: F8B9CC35-CE6A-4DE2-9BD5-A24C65D0A731]
The first JSON route
JSON in Vapor is handled by the Content protocol: a type that conforms to Content is Codable plus
RequestDecodable/ResponseEncodable, with a default media type (JSON). Returning a Content value from a handler
encodes it as the response; req.content.decode(_:) decodes the request body into one. Path components that start
with a colon are parameters: ":id" stores the matched segment in req.parameters, and req.parameters.get(_:as:)
reads it as any LosslessStringConvertible type, returning nil when it is missing or cannot be converted.
Replace routes.swift with the following. It keeps the two generated routes, adds a read route with a path parameter,
and a create route that decodes a JSON body:
import Vapor
struct BookDTO: Content {
var id: Int
var title: String
var author: String
}
struct NewBook: Content {
var title: String
var author: String
}
func routes(_ app: Application) throws {
app.get { req async in
"It works!"
}
app.get("hello") { req async -> String in
"Hello, world!"
}
app.get("books", ":id") { req async throws -> BookDTO in
guard let id = req.parameters.get("id", as: Int.self) else {
throw Abort(.badRequest, reason: "id must be an integer")
}
return BookDTO(id: id, title: "Dune", author: "Frank Herbert")
}
app.post("books") { req async throws -> BookDTO in
let input = try req.content.decode(NewBook.self)
return BookDTO(id: 1, title: input.title, author: input.author)
}
}
Source: Vapor docs: Routing and Vapor docs: Content
Point by point:
-
app.get("books", ":id")registersGET /books/:id; the path is given as separate components, not as one string with slashes ("books/:id"also works, but the component form is the documented one and the form that composes with route groups). -
req.parameters.get("id", as: Int.self)isnilfor/books/abc. ThrowingAbort(.badRequest, reason:)is how a handler reports an HTTP error;Abortconforms toAbortError, which Vapor’sErrorMiddlewareturns into a response with that status and a JSON body. -
The handlers are
async throws: Vapor 4 routes acceptasyncclosures directly and the olderEventLoopFuturestyle is only for legacy code. Closures passed toapp.getare@Sendable; capturing onlySendablevalues (as here) keeps the Swift 6 build clean. -
BookDTOandNewBookare structs.ContentrefinesSendable, so a conforming struct must haveSendablestored properties (which Swift 6 requires to cross isolation boundaries) and a conforming class must satisfy theSendablerules itself (final, immutable state). -
The DTO naming (
BookDTO,NewBook) separates the HTTP shape from the persistence model that Fluent adds later, matching theDTOs/folder the template creates.
Run it and call it:
$ curl -i localhost:8080/books/42
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 49
connection: keep-alive
date: Sat, 17 Oct 2026 11:19:32 GMT
{"author":"Frank Herbert","title":"Dune","id":42}
$ curl -i localhost:8080/books/abc
HTTP/1.1 400 Bad Request
content-type: application/json; charset=utf-8
content-length: 47
connection: keep-alive
date: Sat, 17 Oct 2026 11:19:32 GMT
{"error":true,"reason":"id must be an integer"}
$ curl -i -X POST localhost:8080/books -H 'Content-Type: application/json' -d '{"title":"Dune","author":"Frank Herbert"}'
HTTP/1.1 200 OK
content-type: application/json; charset=utf-8
content-length: 48
connection: keep-alive
date: Sat, 17 Oct 2026 11:19:32 GMT
{"title":"Dune","id":1,"author":"Frank Herbert"}
$ curl -i -X POST localhost:8080/books -H 'Content-Type: application/json' -d '{"title":"Dune"}'
HTTP/1.1 400 Bad Request
content-type: application/json; charset=utf-8
content-length: 152
connection: keep-alive
date: Sat, 17 Oct 2026 11:19:32 GMT
{"error":true,"reason":"No such key 'author' at path ''. No value associated with key CodingKeys(stringValue: \"author\", intValue: nil) (\"author\")."}
Source: Vapor docs: Content
Observations from the real output:
-
JSON object keys are not in declaration order (
authorbeforetitlebeforeid). JSON objects are unordered; do not compare response bodies as strings in tests, decode them instead. -
The create route answers
200 OK, not201 Created. Setting a status is one more step (return aResponse, or conform the returned type toAsyncResponseEncodablewith a status); the Requests and Responses and Error Handling pages show how. -
A body that cannot be decoded is reported as
400 Bad Requestautomatically, with the decoder’s message as the reason. That message names Swift types such asCodingKeys, so a public API usually validates input and returns its own messages; the Validation page shows how. -
The failed decode and the
Abortare both logged atwarninglevel, withmethod,urlandrequest-idmetadata:
[ WARNING ] Abort.400: id must be an integer [method: GET, request-id: 8ACBD295-1E49-4A14-A33B-A3FA785F651B, url: /books/abc, userAgent: [curl/8.7.1]]
If the request has no Content-Type header at all, Vapor cannot pick a decoder and answers 415 Unsupported Media Type
with the reason "Can’t decode data without a content type". If it declares another type, decoding uses that type’s
decoder and fails with a 400 Bad Request whose reason describes the problem: for text/plain it says that plaintext
decoding does not support dictionaries, and curl -d without -H sends application/x-www-form-urlencoded, whose
decoder finds no title field. The Content protocol, content negotiation and custom encoders are covered on the
Content and Codable page.
Listing routes
swift run App routes prints the routes registered on the application and exits without starting a server:
$ swift run App routes
+------+------------+
| GET | / |
+------+------------+
| GET | /hello |
+------+------------+
| GET | /books/:id |
+------+------------+
| POST | /books |
+------+------------+
Source: Vapor docs: Routing, route list
The routes command runs configure(_:) and then prints the router’s table, so it is the quickest way to see what
configure and the route-registering code produced, including routes added by controllers and route groups. With
Fluent the table also contains the /todos routes (the official routing page shows
DELETE /todos/:todoID in its example). Each route also exposes a path-parameter name (:id above), and catch-all
(**) and anything (*) components show up in the same column.
Commands and options
The executable is a Commands host: the first argument that is not an option selects the command. Vapor’s own
help lists what is registered (without Fluent there is no migrate command; Fluent adds it):
$ swift run App --help
Usage: .../App <command>
Commands:
routes Displays all registered routes.
serve Begins serving the app over HTTP.
Use `.../App <command> [--help,-h]` for more information on a command.
$ swift run App serve --help
Usage: .../App serve [--hostname,-H] [--port,-p] [--bind,-b] [--unix-socket]
Begins serving the app over HTTP.
Options:
hostname Set the hostname the server will run on.
port Set the port the server will run on.
bind Convenience for setting hostname and port together.
unix-socket Set the path for the unix domain socket file the server will bind to.
Source: Vapor docs: Commands
The options available on every command come from the environment and logging setup done in entrypoint.swift
(Environment.detect() and LoggingSystem.bootstrap(from:)):
| Option | Meaning | Example |
|---|---|---|
|
the environment: |
|
|
the log level: |
|
|
the host or IP address to bind ( |
|
|
the TCP port to bind ( |
|
|
hostname and port together; cannot be combined sensibly with the two above |
|
|
bind a Unix domain socket instead of TCP |
|
Source: Vapor docs: Server
Results observed on this build:
$ swift run App serve --hostname 0.0.0.0 --port 9090
[ NOTICE ] Server started on http://0.0.0.0:9090
$ swift run App serve -b 127.0.0.1:7000 --env production
[ NOTICE ] Server started on http://127.0.0.1:7000
$ swift run App serve --env staging --log debug -b 127.0.0.1:7001
[ DEBUG ] Could not load .env.staging file: Not found: Unable to open or create file at path '.env.staging', ...
[ DEBUG ] Could not load .env file: Not found: Unable to open or create file at path '.env', ...
[ DEBUG ] Server starting on http://127.0.0.1:7001 (Vapor/HTTPServer.swift:431)
[ NOTICE ] Server started on http://127.0.0.1:7001 (Vapor/HTTPServer.swift:458)
$ swift run App serve --unix-socket /tmp/vapor-gs.sock
[ NOTICE ] Server started on http+unix: /tmp/vapor-gs.sock
Source: Vapor docs: Server
The --env staging run shows that an arbitrary environment name is accepted and that Vapor tries to load dotenv files
(.env.staging first, then .env) from the working directory, logging at debug when they are absent. That is how
local development configuration is supplied without exporting variables; never commit those files. Environment handling
(the Environment type, Environment.get(_:), dotenv precedence and secrets) belongs to the
Configuration and Environment page; here it is enough to know
that development is the default and that production raises the default log level from info to notice.
Executing the program with an unknown command fails with the registered names:
$ swift run App bogus
[ WARNING ] .unknownCommand("bogus", available: ["routes", "serve"])
Swift/ErrorType.swift:254: Fatal error: Error raised at top level: .unknownCommand("bogus", available: ["routes", "serve"])
Common first-build and first-run errors
| Symptom | Cause | Fix |
|---|---|---|
|
the toolchain is older than the |
install a newer toolchain (Xcode or Swiftly; see the installation page) or, for your own manifest, lower the tools version to 6.0, the minimum Vapor 4.122.2 itself builds with. The wording was reproduced locally with a deliberately too-high manifest: |
|
the target does not list |
add the product to the target’s |
errors in a package that previously built, after switching Xcode or toolchain |
stale build artifacts |
|
a platform or availability error mentioning macOS or iOS versions |
the manifest’s |
keep the template’s |
Linux build fails with missing system libraries |
a |
install the development packages listed in the Installation and Toolchain page, or build in the official |
|
another process (often a previous |
stop the other process ( |
|
a non-numeric value was given to |
pass a number |
|
the server is bound to |
bind |
|
the JSON body is missing a required key or has the wrong type |
send the required fields; in a real API validate and translate the message |
a file under |
the server was started from another working directory, or |
run from the project folder or set Xcode’s custom working directory |
A first handler that captures mutable state is the typical Swift 6 stumbling block. Closures passed to app.get are
@Sendable, so mutating a captured var from one, or capturing a non-Sendable class, is a data race: handlers run
concurrently on several event loops. Because Vapor 4 marks its route-registration methods @preconcurrency, the Swift 6
language mode reports these captures as warnings (for example "mutation of captured var 'hits' in concurrently-executing
code") rather than errors, so the build succeeds and the race ships unless you treat the warnings as errors. A mutable
global, on the other hand, is an error in the Swift 6 language mode. Keep request state in the Request, shared state
in an actor or in app.storage, and mutable globals out of routes; the
Swift Concurrency in Vapor page develops this and
Actors, Isolation and Sendable explains the
language side.
Version notes and outdated material
-
swift run Runand aSources/Runexecutable (Vapor 3 and early Vapor 4 templates, including the books written for them) became a single executable target that holds all the code (named after the project by the current template,Appin this section); the template for Vapor 4.122 has noRuntarget. -
vapor xcodeandswift package generate-xcodeprojare gone from the Toolbox 20 and from modern Xcode; openPackage.swiftin Xcode and useswift build/swift run. Likewise thecheck.vapor.shscript and the Vapor APT repository are outdated; the Installation and Toolchain page covers current methods. -
EventLoopFuturereturn types (-> EventLoopFuture<String>) appear in the books and old tutorials. They still work in Vapor 4, but new code usesasync/await, as above. -
The Vapor 3
Router/routes(_ router: Router)function andtry router.get(...)becameroutes(_ app: Application)andapp.get(...)in Vapor 4; Vapor 3 services and containers (config.register(...)) are gone. -
Older books show the entrypoint as
main.swiftwithapp.run(); the current template uses@main enum Entrypoint,Application.makeandapp.execute(), with the old forms deprecated. -
Vapor 5 (5.0.0-beta.4 at the time of writing) is in beta, not released, and changes some APIs. This page’s code is written for Vapor 4.122.x; check the What’s New, Upgrading and Vapor 5 page before moving.