Server-Side Swift with Vapor — Building Web APIs and Backend Services
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
Not using async/await: Vapor fully supports async/await. Avoid callback-based APIs unless necessary.
Exposing sensitive fields: Use
Contentconformance carefully. Create separate DTOs for request and response data.Forgetting migrations: Database schema changes must be applied via migrations in production. Never run auto-migrate in production.
Not handling database errors: Wrap database operations in do/catch and return appropriate HTTP status codes.
Hardcoding configuration: Use environment variables for database URLs, API keys, and other secrets.
Practice Questions
- How does Vapor's routing system differ from Express.js"Express" >}}.js or Flask?
- What is the purpose of Fluent migrations?
- How does ModelTokenAuthenticatable work?
- How would you add Rate Limiting to an endpoint?
- 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
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