Skip to content

Server-Side Swift with Vapor — Building Web APIs and Backend Services

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Server. We cover key concepts, practical examples, and best practices to help you master this topic.

Server-side Swift with Vapor lets you build web APIs, backend services, and real-time applications using the same language as your iOS apps, sharing models and business logic between client and server.

What You'll Learn

  • Vapor project structure and configuration
  • Routing and controller patterns
  • Fluent ORM for database operations
  • Middleware and authentication
  • WebSocket support for real-time features
  • Testing and deployment

Why It Matters

Server-side Swift eliminates the context switch between Swift (iOS) and other backend languages. You share models, validation logic, and networking code between client and server. Vapor is production-ready — used by companies like Lyft, PayPal, and Discord for services handling millions of requests.

Real-World Use

A team builds a social media app. The iOS app and the Vapor backend share a "User" and "Post" model package. The backend exposes a REST API that the iOS app consumes. When the API adds a feature, the iOS app can use it immediately because both sides use the same Codable models.

Learning Path

flowchart LR
  A[Project: CLI Tool
Lesson 37] --> B[Server-Side Swift
You are here] B --> C[App Architecture
Lesson 39] B --> D[Swift Ecosystem
Lesson 40] style B fill:#f90,color:#fff

Installation

# Install Vapor toolbox
brew install vapor

# Create a new project
vapor new MyAPI --template api

# Run
cd MyAPI
swift run

The project creates a standard SPM structure with Vapor dependencies.

Project Structure

MyAPI/
  Package.swift
  Sources/
    App/
      configure.swift    # App configuration
      routes.swift       # Route registration
      Controllers/       # Business logic
      Models/            # Database models
      Middleware/        # Request processing
      Migrations/        # Database migrations
  Tests/
    AppTests/

Hello World Route

import Vapor

func routes(_ app: Application) throws {
    app.get { req async -> String in
        return "Hello, Vapor!"
    }

    app.get("hello", ":name") { req -> String in
        let name = req.parameters.get("name") ?? "World"
        return "Hello, \(name)!"
    }

    app.get("json") { req -> [String: String] in
        return ["message": "Hello from Vapor", "version": "1.0"]
    }
}

Model and Migration

import Fluent
import Vapor

final class User: Model, Content, @unchecked Sendable {
    static let schema = "users"

    @ID(key: .id)
    var id: UUID?

    @Field(key: "name")
    var name: String

    @Field(key: "email")
    var email: String

    @Field(key: "password_hash")
    var passwordHash: String

    @Timestamp(key: "created_at", on: .create)
    var createdAt: Date?

    @Children(for: \.$user)
    var posts: [Post]

    init() {}

    init(id: UUID? = nil, name: String, email: String, passwordHash: String) {
        self.id = id
        self.name = name
        self.email = email
        self.passwordHash = passwordHash
    }
}

final class Post: Model, Content, @unchecked Sendable {
    static let schema = "posts"

    @ID(key: .id)
    var id: UUID?

    @Field(key: "title")
    var title: String

    @Field(key: "content")
    var content: String

    @Parent(key: "user_id")
    var user: User

    @Timestamp(key: "created_at", on: .create)
    var createdAt: Date?

    init() {}

    init(id: UUID? = nil, title: String, content: String, userID: UUID) {
        self.id = id
        self.title = title
        self.content = content
        self.$user.id = userID
    }
}

Migration

import Fluent

struct CreateUser: AsyncMigration {
    func prepare(on database: Database) async throws {
        try await database.schema("users")
            .id()
            .field("name", .string, .required)
            .field("email", .string, .required)
            .field("password_hash", .string, .required)
            .field("created_at", .datetime)
            .unique(on: "email")
            .create()
    }

    func revert(on database: Database) async throws {
        try await database.schema("users").delete()
    }
}

struct CreatePost: AsyncMigration {
    func prepare(on database: Database) async throws {
        try await database.schema("posts")
            .id()
            .field("title", .string, .required)
            .field("content", .string, .required)
            .field("user_id", .uuid, .required,
                   .references("users", "id", onDelete: .cascade))
            .field("created_at", .datetime)
            .create()
    }

    func revert(on database: Database) async throws {
        try await database.schema("posts").delete()
    }
}

Controller

import Vapor
import Fluent

struct UserController: RouteCollection {
    func boot(routes: RoutesBuilder) throws {
        let users = routes.grouped("api", "users")
        users.get(use: index)
        users.post(use: create)
        users.group(":userID") { user in
            user.get(use: show)
            user.delete(use: delete)
        }

        // Protected routes require auth
        let protected = users.grouped(User.authenticator())
        protected.post("me", use: me)
    }

    func index(req: Request) async throws -> [User] {
        try await User.query(on: req.db).all()
    }

    func create(req: Request) async throws -> User {
        let create = try req.content.decode(CreateUserRequest.self)
        let passwordHash = try await req.password.async.hash(create.password)
        let user = User(name: create.name, email: create.email,
                       passwordHash: passwordHash)
        try await user.save(on: req.db)
        return user
    }

    func show(req: Request) async throws -> User {
        guard let user = try await User.find(req.parameters.get("userID"),
                                              on: req.db) else {
            throw Abort(.notFound)
        }
        return user
    }

    func delete(req: Request) async throws -> HTTPStatus {
        guard let user = try await User.find(req.parameters.get("userID"),
                                              on: req.db) else {
            throw Abort(.notFound)
        }
        try await user.delete(on: req.db)
        return .ok
    }

    func me(req: Request) async throws -> User {
        try req.auth.require(User.self)
    }
}

struct CreateUserRequest: Content {
    let name: String
    let email: String
    let password: String
}

Registering Routes

import Vapor

public func configure(_ app: Application) throws {
    // Database
    app.databases.use(DatabaseConfigurationFactory.postgres(
        hostname: Environment.get("DB_HOST") ?? "localhost",
        username: Environment.get("DB_USER") ?? "vapor",
        password: Environment.get("DB_PASS") ?? "password",
        database: Environment.get("DB_NAME") ?? "myapp"
    ), as: .psql)

    // Migrations
    app.migrations.add(CreateUser())
    app.migrations.add(CreatePost())
    try app.autoMigrate().wait()

    // Auth
    app.passwords.use(.bcrypt)

    // Controllers
    try app.register(collection: UserController())
    try app.register(collection: PostController())

    // Middleware
    app.middleware.use(CORSMiddleware())
    app.middleware.use(ErrorMiddleware())
}

Authentication

import Vapor
import Fluent

extension User: ModelAuthenticatable {
    static let usernameKey = \User.$email
    static let passwordHashKey = \User.$passwordHash

    func verify(password: String) throws -> Bool {
        try Bcrypt.verify(password, created: self.passwordHash)
    }
}

// Token-based auth
final class Token: Model, Content, @unchecked Sendable {
    static let schema = "tokens"

    @ID(key: .id)
    var id: UUID?

    @Field(key: "value")
    var value: String

    @Parent(key: "user_id")
    var user: User

    init() {}

    init(value: String, userID: UUID) {
        self.value = value
        self.$user.id = userID
    }
}

extension Token: ModelTokenAuthenticatable {
    static let valueKey = \Token.$value
    static let userKey = \Token.$user

    var isValid: Bool { true }
}

WebSocket Support

import Vapor

func websocketRoutes(_ app: Application) throws {
    app.webSocket("chat") { req, ws in
        print("WebSocket connected")

        ws.onText { ws, text in
            print("Received: \(text)")
            ws.send("Echo: \(text)")
        }

        ws.onClose.whenComplete {
            print("WebSocket disconnected")
        }
    }
}

// Client connects to ws://localhost:8080/chat

Testing

@testable import App
import XCTVapor

final class UserTests: XCTestCase {
    var app: Application!

    override func setUp() async throws {
        app = Application(.testing)
        try await configure(app)
        try await app.autoMigrate()
    }

    override func tearDown() async throws {
        try await app.autoRevert()
        app.shutdown()
    }

    func testCreateUser() throws {
        try app.test(.POST, "api/users",
            beforeRequest: { req in
                try req.content.encode([
                    "name": "Alice",
                    "email": "alice@test.com",
                    "password": "secret123"
                ])
            },
            afterResponse: { res in
                XCTAssertEqual(res.status, .ok)
                let user = try res.content.decode(User.self)
                XCTAssertEqual(user.name, "Alice")
                XCTAssertEqual(user.email, "alice@test.com")
            }
        )
    }

    func testListUsers() throws {
        try app.test(.GET, "api/users") { res in
            XCTAssertEqual(res.status, .ok)
            let users = try res.content.decode([User].self)
            XCTAssertGreaterThanOrEqual(users.count, 0)
        }
    }
}

Deployment

# Build for production
swift build -c release --static-swift-stdlib

# Run on server
./.build/release/Run serve --env production \
    --hostname 0.0.0.0 --port 8080

# Docker
docker build -t myapi .
docker run -p 8080:8080 myapi

Key Takeaways

  • Shared models between iOS and server reduces duplication
  • Fluent ORM supports PostgreSQL, MySQL, SQLite, and MongoDB
  • Vapor's async/await support is native and performant
  • Middleware pattern cleanly separates concerns
  • Testing uses XCTVapor for HTTP-level tests

Common Mistakes

  1. Not using async/await: Vapor fully supports async/await. Avoid callback-based APIs unless necessary.

  2. Exposing sensitive fields: Use Content conformance carefully. Create separate DTOs for request and response data.

  3. Forgetting migrations: Database schema changes must be applied via migrations in production. Never run auto-migrate in production.

  4. Not handling database errors: Wrap database operations in do/catch and return appropriate HTTP status codes.

  5. Hardcoding configuration: Use environment variables for database URLs, API keys, and other secrets.

Practice Questions

  1. How does Vapor's routing system differ from Express.js"Express" >}}.js or Flask?
  2. What is the purpose of Fluent migrations?
  3. How does ModelTokenAuthenticatable work?
  4. How would you add Rate Limiting to an endpoint?
  5. Challenge: Build a real-time chat API with Vapor WebSockets. Users authenticate with tokens, join chat rooms, and receive messages in real time. Add a REST endpoint for message history.

FAQ

Is server-side Swift production-ready?

Yes. Vapor is used in production by companies like Lyft, PayPal, and Zillow. It handles millions of requests per day.

Can I host Vapor on Heroku or AWS?

Yes. Vapor supports Heroku buildpacks, AWS Elastic Beanstalk, Docker, and direct deployment to Linux servers.

How does Vapor compare to Node.js or Python?

Vapor offers similar performance to Node.js with better type safety. It excels when sharing code with iOS apps.

What databases does Fluent support?

Fluent supports PostgreSQL, MySQL, SQLite, and MongoDB. PostgreSQL is recommended for production.

Can I use Vapor for serverless functions?

Not easily. Vapor is a full web framework. For serverless, use Swift AWS Lambda Runtime or Vapor's Lambda-compatible mode.

What's Next

Learn how to structure large iOS apps with App Architecture patterns, or explore the broader Swift Ecosystem.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro