Skip to main content
All articles

MongoDB and SwiftUI: The Decision First, Then the Wiring

Ben Van AkenCo-Founder & CTO12 min read

A lot of people arrive here having searched for something like "swift vs mongodb", which is a bit like searching for "hammer vs plywood". Swift is a language. MongoDB is a database. You do not pick one.

But the search is not silly, because there is a real decision underneath it, and it is usually one of two. Either: I am building a Swift backend, should I put MongoDB behind it or Postgres? Or: I have a SwiftUI app and a MongoDB database, how do those two ever meet?

This post answers both, in that order, because the second answer changed materially in 2025 and most of the tutorials you will find are now describing infrastructure that no longer exists.

Part one: MongoDB or Postgres behind a Swift backend?

If you are writing Vapor or Hummingbird, the honest default is Postgres. That is not a knock on MongoDB; it is a statement about the Swift ecosystem. Fluent's Postgres driver is the best-supported persistence path in server-side Swift, it has migrations, relations and transactions that everyone in your team already understands, and Postgres has had JSONB for over a decade — so "I need to store a flexible document" is not, on its own, a reason to leave.

There are still cases where Mongo is the better call, and they are specific:

  • Documents that are genuinely heterogeneous — CMS content, form builders, product catalogues where every category has different attributes and you are not willing to model 40 sparse columns.
  • Write-heavy append workloads: telemetry, audit trails, event streams, anything where you insert far more than you join.
  • Aggregation pipelines you would otherwise write as several rounds of application-side stitching.
  • Change streams — you want a live feed of "what changed in this collection" without building a message bus first.
  • An existing MongoDB estate. If the rest of the company is already on Atlas, being the one Swift service that needs its own Postgres is a real operational cost.

And the cases where you should not:

  • Your data is relational and you know it. Orders, invoices, users, memberships. Denormalising those into documents means you own consistency by hand, forever.
  • You want an ORM with migrations. Fluent's MongoDB driver exists and works, but the well-trodden path — the one with the most examples, the most Stack Overflow answers, and the fewest surprises — is Fluent plus Postgres.
  • You picked it because "schemaless is faster to start". It is, for about three weeks. Then the schema exists anyway, only now it lives in six places in your Swift code instead of one migration file.

Schemaless does not mean there is no schema. It means the schema is in your application code and nobody wrote it down.

Part two: the three shortcuts that no longer exist

If you follow a 2021-era tutorial on connecting SwiftUI to MongoDB, you will end up at one of three dead ends. This is the part worth reading before you write any code.

The official Swift driver is not maintained

MongoDB announced in August 2023 that it was halting development of the server-side Swift driver. The repository is still there under the Apache 2.0 licence and you can still fork it, but there are no further bug fixes, enhancements or documentation changes coming from MongoDB. Building a 2026 backend on it is a choice to maintain a database driver yourself.

Realm and Atlas Device Sync are gone

Atlas Device Sync and the Atlas Device SDKs — the products most people still call Realm — were deprecated in September 2024 and reached end of life on 30 September 2025. The local Realm database survives as open source, but the cloud sync that made "MongoDB in a SwiftUI app" a sentence anyone could say has been switched off. If your plan was offline-first sync straight to Atlas, that plan needs replacing.

The Atlas Data API was removed too

The other common shortcut was the Atlas Data API and custom HTTPS Endpoints: CRUD over REST, no server of your own. Those were removed on the same date, 30 September 2025. MongoDB's own migration guidance is to put a service you control in front of the database — drivers in your own stack, or serverless functions.

The architecture that is left (and was always the right one)

Your app talks to your API. Your API talks to MongoDB. There is no supported path from a SwiftUI view to an Atlas cluster, and there should not be one — every argument for it dies on the same three points.

  1. A connection string in an app bundle is a published connection string. Anyone can pull the strings out of your IPA in about a minute.
  2. The database has no idea who your users are. Authorisation is per-database-user, not per-app-user, so any credential that can read one customer's documents can read all of them.
  3. You cannot change anything. Rename a field and every shipped version of your app breaks, including the ones on devices that will never update.

A thin Swift service in the middle fixes all three, and it costs you one afternoon.

Server side: MongoKitten

The maintained option is MongoKitten, a pure-Swift, NIO-based driver by Joannis Orlandos. It is also what Vapor's Fluent MongoDB driver is built on, so you are not choosing between them so much as choosing which altitude to work at.

Swift
// Package.swift, server target only.
.package(
  url: "https://github.com/orlandos-nl/MongoKitten.git",
  from: "7.9.0"
)

Connecting is one call. Use connect during boot when you want failures to surface immediately, and lazyConnect in development when you would rather not wait for a cluster handshake on every restart:

Swift
import MongoKitten

let db = try await MongoDatabase.connect(
    to: Environment.get("MONGO_URL") ?? ""
)

// A collection is MongoDB's answer to a table.
let notes = db["notes"]

Model your documents with ordinary Codable types. The only Mongo-specific part is the identifier — MongoDB names it _id and fills it with an ObjectId when you do not supply one, so map it in CodingKeys and keep the rest of your model plain:

Swift
struct Note: Codable, Sendable {
    let id: ObjectId
    let title: String
    let body: String
    let updatedAt: Date

    enum CodingKeys: String, CodingKey {
        case id = "_id"
        case title, body, updatedAt
    }
}

Reads and writes are async and typed. A find returns a cursor you can shape before you spend any memory on it:

Swift
// _id is generated when it is absent.
try await notes.insert([
    "title": "Kickoff",
    "body": "Scoped the first milestone.",
    "updatedAt": Date()
])

// Cursor operations, then decode at the end.
let recent = try await notes
    .find("updatedAt" >= cutoff)
    .sort(["updatedAt": .descending])
    .limit(50)
    .decode(Note.self)
    .drain()

Indexes are the difference between a demo and a service. Declare them once at boot; creating an index that already exists is a no-op:

Swift
try await notes.buildIndexes {
    UniqueIndex(
        named: "unique-slug",
        field: "slug"
    )

    TTLIndex(
        named: "expire-drafts",
        field: "createdAt",
        expireAfterSeconds: 60 * 60 * 24 * 30
    )
}

Or Fluent, if you want models and migrations

If the appeal of Mongo for you is operational rather than architectural — the team runs Atlas, you want Fluent's ergonomics — the driver plugs into Fluent exactly like Postgres does:

Swift
import Fluent
import FluentMongoDriver

try app.databases.use(
    .mongo(connectionString: mongoURL),
    as: .mongo
)

Be clear-eyed about what that costs. Fluent gives you one query model across every driver, which means the parts of MongoDB you may have chosen it for — aggregation pipelines, change streams, nested-document updates — are not in Fluent's query builder. You will end up reaching past Fluent to MongoKitten for those, and a codebase with two ways to talk to the same database is worse than a codebase with one. Pick the altitude and stay there.

The API layer, and why it should not expose your documents

The route is unremarkable, and that is the point. What matters is the type it returns: a DTO, not the stored document. The req.mongo accessor below is a four-line Request extension that hands out the shared database — MongoKitten's README has the exact snippet.

Swift
struct NoteDTO: Content {
    let id: String
    let title: String
    let body: String
    let updatedAt: Date
}

app.get("notes") { req async throws -> [NoteDTO] in
    let notes = try await req.mongo["notes"]
        .find()
        .sort(["updatedAt": .descending])
        .limit(50)
        .decode(Note.self)
        .drain()

    return notes.map { note in
        NoteDTO(
            id: note.id.hexString,
            title: note.title,
            body: note.body,
            updatedAt: note.updatedAt
        )
    }
}

The mapping from ObjectId to a plain hex String is the load-bearing line. Your app should not know that the store underneath is MongoDB — it should receive an opaque identifier it can round-trip. Do this and you can add a caching layer, shard the collection, or migrate to Postgres in two years without shipping a client update. Skip it, and your wire format is now your database schema.

The SwiftUI side

Once there is a JSON API, the client is the boring part, which is exactly what you want. An @Observable store, URLSession, and one .task:

Swift
@Observable
@MainActor
final class NoteStore {
    private(set) var notes: [NoteDTO] = []
    private(set) var loadFailed = false

    private let base: URL
    private let decoder: JSONDecoder

    init(base: URL) {
        self.base = base
        let decoder = JSONDecoder()
        decoder.dateDecodingStrategy = .iso8601
        self.decoder = decoder
    }

    func load() async {
        do {
            let url = base.appending(path: "notes")
            let (data, _) = try await URLSession
                .shared
                .data(from: url)
            notes = try decoder.decode(
                [NoteDTO].self,
                from: data
            )
            loadFailed = false
        } catch {
            loadFailed = true
        }
    }
}
Swift
struct NoteListScreen: View {
    @State private var store = NoteStore(
        base: AppConfig.apiBaseURL
    )

    var body: some View {
        List(store.notes, id: \.id) { note in
            VStack(alignment: .leading) {
                Text(note.title).font(.headline)
                Text(note.body)
                    .font(.subheadline)
                    .foregroundStyle(.secondary)
            }
        }
        .task { await store.load() }
        .refreshable { await store.load() }
    }
}

Nothing in that file mentions MongoDB, and nothing should. If you later want offline support, this is also the layer where it belongs: cache the decoded DTOs in SwiftData or a file, render from the cache, refresh in the background. That is the replacement for Device Sync, and it is more work than sync was — but it is work you control.

Change streams, if you want live data

This is the one Mongo feature with no clean Postgres equivalent, and it is worth knowing about. A change stream is an async sequence of everything that happens to a collection, which you can fan out to connected clients over WebSocket or server-sent events:

Swift
let stream = try await notes.watch(type: Note.self)

for try await change in stream {
    guard let note = change.fullDocument else {
        continue
    }
    await broadcaster.publish(note)
}

What we would actually do

For most client projects: Postgres and Fluent, because the boring choice is a feature and Swift's tooling around it is the strongest. For a project that is genuinely document-shaped, or where the company already runs Atlas: MongoKitten directly, no Fluent, with DTOs at the API edge.

In both cases the app talks to a Swift service over JSON and has no idea what the storage engine is. That indifference is the whole design. It is what let us swap a datastore on a client project without touching a line of SwiftUI, and it is why the "connect your app straight to the database" products keep getting switched off — they trade away the one boundary that makes an app maintainable.

The short version

  • Swift and MongoDB are not alternatives. The real question is Mongo or Postgres behind your Swift service, and the default answer is Postgres unless your data is genuinely document-shaped.
  • The official MongoDB Swift driver stopped being developed in August 2023. Use MongoKitten.
  • Atlas Device Sync, the Atlas Device SDKs, the Data API and custom HTTPS Endpoints all reached end of life on 30 September 2025. There is no supported direct path from an app to Atlas.
  • Put a Vapor or Hummingbird service in the middle, return DTOs rather than stored documents, and keep the client ignorant of the database.
  • Index at boot, page your queries, and only reach for change streams once you have a replica set to run them against.

Need a Swift backend that will still make sense in two years?

We design and ship server-side Swift alongside the apps that consume it. Book a call and let's look at your data model together.