Serving an SPA from a Swift CLI

Posted Thursday, August 13, 2026.

This post shows how to co-locate a Single Page Application (SPA) with a Swift Package Manager project and serve the SPA's static output directly from a Swift CLI. The approach copies the SPA build artifacts into an SPM target's resources and uses a FileMiddleware-backed Hummingbird app to serve them, with optional Fluent integration for a simple API.

TL;DR

  • Build the SPA (e.g., Next.js, Vite) and copy its out/build directory into an SPM target resource.
  • Use Bundle.module.resourcePath and FileMiddleware to serve static assets and index.html.
  • Code structure: the CLI target runs the application; the SPA target contains server code and resources.
  • The SPA can make API requests back to the web server.

Package structure

You can co-locate (monorepo) your Swift and JS/TS code. What matters is that we take the SPA's build output (HTML/CSS/JS) and copy it into one of our SPM targets.

Node

Bootstrap a SPA however you prefer. I created a simple Next.js app. Run your usual build command (for example npm run build with output: "export" set in next.config.js) and place the resulting static output in SPA/out for the example to pick up.

Swift

import PackageDescription

let package = Package(
name: "cli",
platforms: [...],
products: [
.executable(name: "cli", targets: ["CLI"]),
...
],
traits: [...],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.7.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.10.0"),
.package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.20.0"),
// Optional
.package(url: "https://github.com/hummingbird-project/hummingbird-fluent.git", from: "2.0.0"),
.package(url: "https://github.com/vapor/fluent-sqlite-driver.git", from: "4.8.0"),
],
targets: [
.executableTarget(
name: "CLI",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.target(name: "SPA"),
],
swiftSettings: ...
),
.target(
name: "SPA",
dependencies: [
.product(name: "Hummingbird", package: "hummingbird"),
.product(name: "Logging", package: "swift-log"),
// Optional
.product(name: "HummingbirdFluent", package: "hummingbird-fluent"),
.product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver")
],
resources: [
.copy("out") // This is key!
],
swiftSettings: ...
)
]
)

Example entry point CLI/CLI.swift:

import ArgumentParser
import SPA

@main
struct CLI: AsyncParsableCommand {
static let configuration: CommandConfiguration = .init(
commandName: "spa",
abstract: "Run SPA"
)

func run() async throws {
let app = try await buildSpa()
try await app.runService()
}
}

SPA/App.swift:

import Foundation
import Hummingbird
import Logging
// Optional
import FluentSQLiteDriver
import HummingbirdFluent

typealias AppRequestContext = BasicRequestContext

package func buildSpa() async throws -> some ApplicationProtocol {
let logger = Logger(label: "spa")

// Optional
let fluent = Fluent(logger: logger)
fluent.databases.use(.sqlite(.memory), as: .sqlite)
await fluent.migrations.add(Migration001())
try await fluent.migrate()

let router = Router(context: AppRequestContext.self)
let base = Bundle.module.resourcePath!.appending("/out") // Points to the bundled SPA files extracted at runtime.

router.addMiddleware {
// LogRequestsMiddleware(.debug)
FileMiddleware(base, searchForIndexHtml: true, logger: logger) // Searches for index.html so client-side routes resolve correctly.
CORSMiddleware(
allowOrigin: .all,
allowMethods: [.options, .head, .get, .post, .put, .delete]
)
}

router.addRoutes(CrudController(fluent: fluent).endpoints)

var app = Application(
router: router,
configuration: .init(
address: .hostname("127.0.0.1", port: 3000),
serverName: "spa"
),
logger: logger
)

// Optional
app.addServices(fluent)

return app
}

Optional: lightweight Fluent models and migrations used by the example API (in-memory SQLite for demos). SPA/Fluent.swift:

import FluentKit
import Hummingbird

final class Crud: Model, ResponseCodable, @unchecked Sendable {
static let schema = "cruds"

@ID(custom: "id", generatedBy: .database) var id: Int?
@Field(key: "name") var name: String

init() {}

init(
id: Int? = nil,
name: String
) {
self.id = id
self.name = name
}
}

struct Migration001: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema("cruds")
.field(.id, .int, .identifier(auto: true))
.field("name", .string, .required)
.create()
}

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

API endpoints the SPA calls (CRUD example). These are reachable at the same origin when served from the CLI. SPA/Controller.swift:

import Foundation
import Hummingbird
import FluentKit
import HummingbirdFluent

struct CrudController {
let fluent: Fluent

var endpoints: RouteCollection<AppRequestContext> {
RouteCollection(context: AppRequestContext.self)
.get("get", use: fetch)
.post("post", use: create)
.put("put/:id", use: update)
.delete("delete/:id", use: delete)
}

struct FetchResponse: ResponseCodable {
let cruds: [Crud]
}

@Sendable func fetch(request: Request, context: some RequestContext) async throws -> FetchResponse {
let cruds = try await Crud.query(on: fluent.db()).all()

return try await FetchResponse(cruds: cruds)
}

struct CreateRequest: ResponseCodable {
let name: String
}

@Sendable func create(request: Request, context: some RequestContext) async throws -> HTTPResponse.Status {
let request = try await request.decode(as: CreateRequest.self, context: context)
try await Crud(name: request.name).save(on: fluent.db())
return .created
}

struct UpdateRequest: ResponseCodable {
let name: String
}

@Sendable func update(request: Request, context: some RequestContext) async throws -> HTTPResponse.Status {
let id = try context.parameters.require("id", as: Crud.IDValue.self)
let request = try await request.decode(as: UpdateRequest.self, context: context)
let crud = try await Crud
.find(id, on: fluent.db())
.or(throw: .notFound) // helper extension
crud.name = request.name
try await crud.save(on: fluent.db())
return .noContent
}

@Sendable func delete(request: Request, context: some RequestContext) async throws -> HTTPResponse.Status {
let id = try context.parameters.require("id", as: Crud.IDValue.self)
let crud = try await Crud
.find(id, on: fluent.db())
.or(throw: .notFound) // helper extension
try await crud.delete(on: fluent.db())
return .noContent
}
}

Summary

The example CLI binds to 127.0.0.1:3000. When you access http://localhost:3000/ in the browser, the SPA and API share the same origin (host + port). In that case, client fetches using relative URLs (for example fetch('/get')) will automatically go to the CLI server. You can configure the CLI to bind to a different host or port; if you do, make sure the SPA is built or configured to use the correct API base URL (for example via an environment variable or a build-time substitution). This example does not include that configuration step.

As always, if you know of a better way to do something, please let me know!


Tagged With: