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

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:

FilePurpose
Sources/App/entrypoint.swiftCreates the Application instance and calls configure
Sources/App/configure.swiftDatabase setup, middleware registration, migration registration
Sources/App/routes.swiftRoute 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), accessing film.characters after 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/swift buildpack.

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.swift via .connectionPoolTimeout and .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 debug during development to see every request, response status, and query execution time logged to the console. Switch to --log warning in production to reduce I/O overhead.

When to Use (and When Not To)

ScenarioRecommendation
iOS app needs a bespoke API with complex Swift business logicVapor is a strong fit — shared models, shared types, one language
Team already has a production Node.js or Python APIDon’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 deploymentVapor is designed for long-running servers; AWS Lambda + Swift is a better fit
Rapid prototyping with no databaseVapor with SQLite in-memory is excellent; spin up a full API in minutes
Large team with mixed iOS/backend engineersShared Swift models are the killer argument — type safety across the boundary
Heavy data science or ML pipeline workloadsPython 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, and Result patterns you already know.
  • Route handlers are async throws closures or controller methods that receive a Request and return Content-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 prevent routes.swift from becoming a monolith.
  • A shared Swift package with Codable DTOs 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.