Swift on the Server: Building REST APIs with Vapor
The same PixarFilm struct that powers your iOS app’s data layer can power the API it fetches from. One Codable
model, one set of validation rules, one language across the entire stack. Vapor makes that possible — and it runs on the
same Swift concurrency model you already know from async/await on iOS.
This guide walks through building a production-shaped REST API for a Pixar film catalog: defining routes, modeling data
with Fluent ORM, organizing routes into controllers, adding middleware, and sharing Codable models between the server
and the iOS app. Deployment, JWT authentication, and WebSocket support are introduced at the advanced level but not
built out step by step — those deserve dedicated posts.
Note: This post assumes familiarity with async/await and Codable. If either is new to you, read those first.
Contents
- The Problem: The Black Box Server
- Setting Up a Vapor Project
- Vapor’s App Structure
- Defining Routes
- Modeling Data with Fluent ORM
- Controllers: Organizing Routes at Scale
- Middleware: Logging, CORS, and Authentication
- Sharing Models with the iOS App
- Deployment
- Advanced: WebSockets, JWT, and Testing
- Performance Considerations
- When to Use (and When Not To)
- Summary
The Problem: The Black Box Server
Most iOS developers have a backend they don’t control. A Python Flask API, a Node.js Express server, a third-party
endpoint — the contract is a JSON schema in a Confluence doc that may or may not be up to date. When the server returns
a field named releaseYear but your Codable model expects release_year, you find out at runtime.
Here is the hidden cost:
// On the iOS side — you define this
struct PixarFilm: Codable {
let id: UUID
let title: String
let releaseYear: Int // camelCase, as Swift convention dictates
}
// Meanwhile, the Python server returns:
// { "id": "...", "title": "Toy Story", "release_year": 1995 }
// The decode silently fails — releaseYear is nil unless you write a custom CodingKeys
This is a contract mismatch that a shared Swift model would catch at compile time. The real appeal of Vapor isn’t performance or syntax — it is type-safe API contracts enforced by the compiler across both client and server.
Setting Up a Vapor Project
Apple Docs: Swift Package Manager — Swift
Vapor is distributed via Swift Package Manager. The Vapor toolbox provides a project scaffolding CLI.
# Install the Vapor toolbox via Homebrew
brew install vapor
# Scaffold a new project
vapor new PixarFilmsAPI
# Navigate into the project
cd PixarFilmsAPI
# Open in Xcode
open Package.swift
The scaffolded project asks whether you want Fluent (the ORM) and which database driver you prefer. Select Fluent and SQLite for local development — you can swap to PostgreSQL for production later.
The resulting Package.swift includes the core dependencies:
// Package.swift — simplified
let package = Package(
name: "PixarFilmsAPI",
platforms: [.macOS(.v13)],
dependencies: [
.package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/fluent.git", from: "4.0.0"),
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.0.0"),
],
targets: [
.executableTarget(
name: "App",
dependencies: [
.product(name: "Vapor", package: "vapor"),
.product(name: "Fluent", package: "fluent"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"),
]
),
]
)
Vapor’s App Structure
Vapor Docs: Application
A Vapor project has three key entry points:
| File | Purpose |
|---|---|
Sources/App/entrypoint.swift | Creates the Application instance and calls configure |
Sources/App/configure.swift | Database setup, middleware registration, migration registration |
Sources/App/routes.swift | Route registration — maps HTTP paths to handler closures or controllers |
configure.swift is where application-level infrastructure is wired together:
import Vapor
import Fluent
import FluentSQLiteDriver
public func configure(_ app: Application) async throws {
// Use SQLite for local development
app.databases.use(.sqlite(.file("pixar_films.db")), as: .sqlite)
// Register Fluent migrations
app.migrations.add(CreatePixarFilm())
app.migrations.add(CreatePixarCharacter())
// Run migrations automatically on startup
try await app.autoMigrate()
// Register routes
try routes(app)
}
Defining Routes
Vapor Docs: Routing
Routes in Vapor map an HTTP method + path combination to an async handler closure or a controller method. Handlers
receive a Request and return anything Content-conformant (which
Codable types are automatically).
Basic CRUD Routes
import Vapor
import Fluent
func routes(_ app: Application) throws {
let films = app.grouped("films")
// GET /films — return all films
films.get { req async throws -> [PixarFilm] in
try await PixarFilm.query(on: req.db).all()
}
// GET /films/:filmID — return one film
films.get(":filmID") { req async throws -> PixarFilm in
guard let id = req.parameters.get("filmID", as: UUID.self) else {
throw Abort(.badRequest, reason: "filmID must be a valid UUID")
}
guard let film = try await PixarFilm.find(id, on: req.db) else {
throw Abort(.notFound, reason: "Film not found")
}
return film
}
// POST /films — create a new film
films.post { req async throws -> PixarFilm in
// Vapor validates that the request body decodes to PixarFilm
let film = try req.content.decode(PixarFilm.self)
try await film.save(on: req.db)
return film
}
// PATCH /films/:filmID — update a film's title
films.patch(":filmID") { req async throws -> PixarFilm in
guard let id = req.parameters.get("filmID", as: UUID.self),
let film = try await PixarFilm.find(id, on: req.db) else {
throw Abort(.notFound)
}
let update = try req.content.decode(PixarFilmUpdate.self)
if let title = update.title { film.title = title }
if let year = update.releaseYear { film.releaseYear = year }
try await film.save(on: req.db)
return film
}
// DELETE /films/:filmID — delete a film
films.delete(":filmID") { req async throws -> HTTPStatus in
guard let id = req.parameters.get("filmID", as: UUID.self),
let film = try await PixarFilm.find(id, on: req.db) else {
throw Abort(.notFound)
}
try await film.delete(on: req.db)
return .noContent
}
}
Vapor’s Abort type is the idiomatic way to return HTTP errors. Throwing Abort(.notFound) produces a 404 response
with a JSON error body automatically.
Request Validation
Vapor integrates with Validatable to enforce input rules before a
handler runs.
struct CreateFilmRequest: Content, Validatable {
let title: String
let releaseYear: Int
static func validations(_ validations: inout Validations) {
validations.add("title", as: String.self, is: !.empty && .count(1...200))
validations.add("releaseYear", as: Int.self, is: .range(1995...2100))
}
}
films.post("validated") { req async throws -> PixarFilm in
// Automatically returns 422 if validation fails, with field-level error messages
try CreateFilmRequest.validate(content: req)
let body = try req.content.decode(CreateFilmRequest.self)
let film = PixarFilm(title: body.title, releaseYear: body.releaseYear)
try await film.save(on: req.db)
return film
}
Modeling Data with Fluent ORM
Vapor Docs: Fluent
Fluent is Vapor’s ORM. It provides type-safe database access through Swift property wrappers, query builders, and schema migrations.
Defining a Model
import Fluent
import Vapor
final class PixarFilm: Model, Content, @unchecked Sendable {
// Table name in the database
static let schema = "pixar_films"
// Primary key — Fluent manages UUID generation
@ID(key: .id)
var id: UUID?
@Field(key: "title")
var title: String
@Field(key: "release_year")
var releaseYear: Int
@Field(key: "director")
var director: String
// One film has many characters
@Children(for: \.$film)
var characters: [PixarCharacter]
// Required by Fluent — called when hydrating from the database
init() {}
init(id: UUID? = nil, title: String, releaseYear: Int, director: String) {
self.id = id
self.title = title
self.releaseYear = releaseYear
self.director = director
}
}
The @unchecked Sendable annotation satisfies Swift 6’s strict concurrency checking for Fluent models, which use
class-based storage that the compiler cannot automatically verify as safe. This is the Fluent team’s recommended
approach until a Sendable-aware rewrite ships.
Relationships
final class PixarCharacter: Model, Content, @unchecked Sendable {
static let schema = "pixar_characters"
@ID(key: .id)
var id: UUID?
@Field(key: "name")
var name: String
@Field(key: "voice_actor")
var voiceActor: String
// Many characters belong to one film
@Parent(key: "film_id")
var film: PixarFilm
init() {}
init(id: UUID? = nil, name: String, voiceActor: String, filmID: UUID) {
self.id = id
self.name = name
self.voiceActor = voiceActor
self.$film.id = filmID
}
}
Migrations
Migrations define the database schema changes. Each migration has an up method (apply) and a down method (revert).
import Fluent
struct CreatePixarFilm: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema("pixar_films")
.id()
.field("title", .string, .required)
.field("release_year", .int, .required)
.field("director", .string, .required)
.create()
}
func revert(on database: Database) async throws {
try await database.schema("pixar_films").delete()
}
}
struct CreatePixarCharacter: AsyncMigration {
func prepare(on database: Database) async throws {
try await database.schema("pixar_characters")
.id()
.field("name", .string, .required)
.field("voice_actor", .string, .required)
.field("film_id", .uuid, .required, .references("pixar_films", "id"))
.create()
}
func revert(on database: Database) async throws {
try await database.schema("pixar_characters").delete()
}
}
Querying with Fluent
Fluent’s query builder provides type-safe access to the database. Property wrapper keyPaths drive filter expressions, eliminating raw SQL strings.
// Fetch all films from 1999 onwards, sorted by release year
let classicFilms = try await PixarFilm.query(on: req.db)
.filter(\.$releaseYear >= 1999)
.sort(\.$releaseYear, .ascending)
.all()
// Eager-load characters alongside films (avoids N+1 queries)
let filmsWithCharacters = try await PixarFilm.query(on: req.db)
.with(\.$characters)
.all()
Warning: Without
.with(\.$characters), accessingfilm.charactersafter fetching triggers a separate database query per film — the classic N+1 problem. Always eager-load relationships you know you’ll access.
Controllers: Organizing Routes at Scale
Defining all routes as closures in routes.swift becomes unmanageable beyond a handful of endpoints. Vapor controllers
group related routes into a single type conforming to RouteCollection.
import Vapor
import Fluent
struct FilmController: RouteCollection {
func boot(routes: RoutesBuilder) throws {
let films = routes.grouped("films")
films.get(use: index)
films.post(use: create)
films.group(":filmID") { film in
film.get(use: show)
film.patch(use: update)
film.delete(use: delete)
}
}
// GET /films
func index(req: Request) async throws -> [PixarFilm] {
try await PixarFilm.query(on: req.db).all()
}
// GET /films/:filmID
func show(req: Request) async throws -> PixarFilm {
guard let film = try await PixarFilm.find(
req.parameters.get("filmID"),
on: req.db
) else {
throw Abort(.notFound)
}
return film
}
// POST /films
func create(req: Request) async throws -> PixarFilm {
let film = try req.content.decode(PixarFilm.self)
try await film.save(on: req.db)
return film
}
// PATCH /films/:filmID
func update(req: Request) async throws -> PixarFilm {
guard let film = try await PixarFilm.find(
req.parameters.get("filmID"),
on: req.db
) else {
throw Abort(.notFound)
}
let patch = try req.content.decode(PixarFilmUpdate.self)
if let title = patch.title { film.title = title }
if let year = patch.releaseYear { film.releaseYear = year }
try await film.save(on: req.db)
return film
}
// DELETE /films/:filmID
func delete(req: Request) async throws -> HTTPStatus {
guard let film = try await PixarFilm.find(
req.parameters.get("filmID"),
on: req.db
) else {
throw Abort(.notFound)
}
try await film.delete(on: req.db)
return .noContent
}
}
Register the controller in routes.swift:
func routes(_ app: Application) throws {
try app.register(collection: FilmController())
try app.register(collection: CharacterController())
}
Middleware: Logging, CORS, and Authentication
Vapor Docs: Middleware
Middleware intercepts requests before they reach route handlers and responses before they’re sent to clients. Vapor includes production-ready middleware for the most common needs.
Logging and Error Middleware
These are registered globally in configure.swift:
// Built-in middleware — registered automatically in non-production environments
app.middleware.use(ErrorMiddleware.default(environment: app.environment))
app.middleware.use(FileMiddleware(publicDirectory: app.directory.publicDirectory))
CORS Middleware
If your Vapor API serves a web frontend on a different origin (e.g., your Next.js dashboard at
dashboard.pixar-catalog.com), register CORS headers:
let corsConfiguration = CORSMiddleware.Configuration(
allowedOrigin: .custom("https://dashboard.pixar-catalog.com"),
allowedMethods: [.GET, .POST, .PATCH, .DELETE, .OPTIONS],
allowedHeaders: [.accept, .authorization, .contentType]
)
app.middleware.use(CORSMiddleware(configuration: corsConfiguration))
Bearer Token Authentication
Vapor’s vapor/jwt package adds JWT authentication. For simple API key auth without
JWT, use a custom BearerAuthenticator.
struct APIKeyAuthenticator: AsyncBearerAuthenticator {
func authenticate(bearer: BearerAuthorization, for req: Request) async throws {
// In production: look up the token in your database
guard bearer.token == Environment.get("API_SECRET_KEY") else {
return // Returning without setting req.auth means authentication failed
}
// Set an authenticated user on the request
req.auth.login(AdminUser())
}
}
// Apply to a route group — all routes in this group require authentication
let protected = app.grouped(APIKeyAuthenticator())
.grouped(AdminUser.guardMiddleware())
protected.post("films") { req async throws -> PixarFilm in
let film = try req.content.decode(PixarFilm.self)
try await film.save(on: req.db)
return film
}
Sharing Models with the iOS App
This is where the full-stack Swift advantage becomes concrete. A Swift package containing your shared Codable models
can be imported by both the Vapor server and the iOS app. Changing a field name in the shared model immediately breaks
the build on both sides — the compiler enforces the contract.
Creating the Shared Package
mkdir PixarSharedModels && cd PixarSharedModels
swift package init --name PixarSharedModels --type library
Define models with Codable only — no Vapor or Fluent imports:
// Sources/PixarSharedModels/Models.swift
import Foundation
public struct PixarFilmDTO: Codable, Sendable, Identifiable {
public let id: UUID
public let title: String
public let releaseYear: Int
public let director: String
public init(id: UUID, title: String, releaseYear: Int, director: String) {
self.id = id
self.title = title
self.releaseYear = releaseYear
self.director = director
}
}
public struct CreateFilmRequest: Codable, Sendable {
public let title: String
public let releaseYear: Int
public let director: String
public init(title: String, releaseYear: Int, director: String) {
self.title = title
self.releaseYear = releaseYear
self.director = director
}
}
Add the package as a dependency in both Package.swift files. On the server side, convert Fluent models to DTOs before
returning them:
extension PixarFilm {
// Convert the Fluent model to the shared DTO for API responses
func toDTO() throws -> PixarFilmDTO {
try PixarFilmDTO(
id: requireID(),
title: title,
releaseYear: releaseYear,
director: director
)
}
}
// In the route handler
films.get { req async throws -> [PixarFilmDTO] in
let films = try await PixarFilm.query(on: req.db).all()
return try films.map { try $0.toDTO() }
}
On the iOS side, import the same package and use the shared DTO directly:
// iOS networking layer
import PixarSharedModels
func fetchFilms() async throws -> [PixarFilmDTO] {
let url = URL(string: "https://api.pixar-catalog.com/films")!
let (data, _) = try await URLSession.shared.data(from: url)
return try JSONDecoder().decode([PixarFilmDTO].self, from: data)
}
Any mismatch between what the server sends and what the iOS app expects is now a compile-time error rather than a runtime crash.
Deployment
Vapor runs anywhere Swift can compile: Linux servers on AWS, Google Cloud, or VPS providers. Three popular zero-config options for getting a Vapor API live quickly:
- Railway — Docker-based deployment; detects Vapor projects automatically. Free tier available.
- Fly.io — Global edge deployment with persistent volumes. Good fit for PostgreSQL.
- Heroku — Buildpack for Swift exists; requires the
heroku/swiftbuildpack.
For production, swap SQLite for PostgreSQL by replacing the driver in configure.swift:
// Production database configuration
app.databases.use(
.postgres(url: Environment.get("DATABASE_URL") ?? ""),
as: .psql
)
Advanced: WebSockets, JWT, and Testing
WebSockets
Vapor has first-class WebSocket support for real-time features — think a live character leaderboard for Pixar trivia.
app.webSocket("trivia", "live") { req, ws in
ws.onText { ws, text in
// Broadcast incoming messages to all connected clients
await ws.send("Echo: \(text)")
}
ws.onClose.whenComplete { _ in
print("Client disconnected")
}
}
Apple Docs: URLSessionWebSocketTask — Foundation — connects from the iOS side.
JWT Authentication
The vapor/jwt package adds signed JWT support. Tokens carry claims (user ID, roles,
expiry) and are validated cryptographically — no database lookup per request.
import JWT
struct FilmAccessToken: JWTPayload {
var subject: SubjectClaim // userID
var expiration: ExpirationClaim
var isAdmin: Bool
func verify(using algorithm: some JWTAlgorithm) async throws {
try expiration.verifyNotExpired()
}
}
Testing Vapor Apps
The vapor/testing package provides a testable() application helper and XCTVapor assertions:
import XCTVapor
final class FilmAPITests: XCTestCase {
func testGetFilmsReturnsArray() async throws {
let app = try await Application.make(.testing)
defer { app.shutdown() }
try await configure(app)
try await app.test(.GET, "films") { res async in
XCTAssertEqual(res.status, .ok)
let films = try res.content.decode([PixarFilmDTO].self)
XCTAssertFalse(films.isEmpty)
}
}
}
Tests run fully in-process against an in-memory SQLite database — no Docker, no network calls.
Performance Considerations
Vapor is built on SwiftNIO, Apple’s non-blocking networking framework. The threading model mirrors Node.js’s event loop: a small pool of threads handles many concurrent connections without blocking.
Practical implications for your Vapor code:
- Never block the event loop. CPU-bound work (image processing, heavy JSON transformation) should be dispatched to a
background executor using
req.application.threadPool.runIfActive. - Database queries are already non-blocking. Fluent’s async API returns control to the event loop while waiting for the database.
- Connection pooling is automatic. Fluent maintains a connection pool per database driver. The default pool size is
configurable in
configure.swiftvia.connectionPoolTimeoutand.maxConnectionsPerEventLoop.
In TechEmpower Framework Benchmarks, Vapor consistently places in the top tier of server frameworks across JSON serialization, single-query, and multiple-query benchmarks — comparable to or exceeding typical Node.js and Go throughput on equivalent hardware.
Tip: Run
vapor run serve --log debugduring development to see every request, response status, and query execution time logged to the console. Switch to--log warningin production to reduce I/O overhead.
When to Use (and When Not To)
| Scenario | Recommendation |
|---|---|
| iOS app needs a bespoke API with complex Swift business logic | Vapor is a strong fit — shared models, shared types, one language |
| Team already has a production Node.js or Python API | Don’t replace it for the sake of Swift — integration cost outweighs the benefit |
| You need real-time features (chat, live data) | Vapor’s WebSocket support handles this natively |
| Serverless / Functions-as-a-Service deployment | Vapor is designed for long-running servers; AWS Lambda + Swift is a better fit |
| Rapid prototyping with no database | Vapor with SQLite in-memory is excellent; spin up a full API in minutes |
| Large team with mixed iOS/backend engineers | Shared Swift models are the killer argument — type safety across the boundary |
| Heavy data science or ML pipeline workloads | Python ecosystem is far more mature here; use Swift for the client layer |
Summary
- Vapor gives iOS engineers a type-safe server written in Swift, using the same
async/await,Codable, andResultpatterns you already know. - Route handlers are
async throwsclosures or controller methods that receive aRequestand returnContent-conformant types. - Fluent ORM models the database with Swift property wrappers (
@Field,@Parent,@Children) and type-safe query builders that eliminate raw SQL strings. - Controllers (
RouteCollection) group related endpoints and preventroutes.swiftfrom becoming a monolith. - A shared Swift package with
CodableDTOs creates a compile-time contract between your Vapor server and iOS app — mismatched fields fail the build, not the user. - Vapor is built on SwiftNIO’s non-blocking I/O; never block the event loop with synchronous work.
Owning both ends of the network contract changes how you design features. Once you’re comfortable with Vapor’s basics, exploring Networking Layer Architecture shows how to structure the iOS client side to match the type-safe API patterns you just built on the server.