iOS Backend for Swift Apps: Postgres, Auth and StoreKit
An iOS backend for a Swift app usually has to do five things: store data per user, sign people in (often with Apple), hold files, send push notifications through APNs and check StoreKit purchases on the server. StitchDB covers all five with one project and a Swift package, so you write async Swift against a real Postgres database instead of standing up your own API. This guide builds the pieces in order, with code that follows the StitchDB Swift SDK.
#The stack at a glance
| Concern | In Swift | On the server |
|---|---|---|
| Data | stitchdb.from("todos") with Codable structs | Postgres + Row Level Security |
| Sign-in | stitchdb.auth.*, ASWebAuthenticationSession | Auth API, JWT per user |
| Session | Keychain, refreshed for you | 1-hour access token by default |
| Files | stitchdb.storage.from(...) | Buckets + policies on storage.objects |
| Push | stitchdb.push.register(.apns(...)) | APNs with your .p8 key |
| Purchases | stitchdb.purchases.sync(verification) | Checked with Apple |
#1. Add the package
In Xcode choose File → Add Package Dependencies… and paste https://github.com/stitchdb/stitchdb-swift.git. Or in Package.swift:
dependencies: [
.package(url: "https://github.com/stitchdb/stitchdb-swift.git", branch: "main"),
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "StitchDB", package: "stitchdb-swift")]),
]You need Swift 6.1 (Xcode 16.3) or newer. The SDK runs on iOS, macOS, tvOS, watchOS, visionOS and Linux.
#2. One shared client
import StitchDB
let stitchdb = StitchClient(
stitchURL: URL(string: "https://<project-ref>.p.stitchdb.com")!,
stitchKey: "<anon-key>"
)Create it once, for example as a global or in your app's environment. On Apple platforms the session is kept in the keychain, so users stay signed in between launches without extra code.
Ship only the anon key in the app. Strings in an IPA can be extracted; the anon key is designed for that, because Row Level Security decides what it can do. The service_role key bypasses those rules and must stay on a server.
#3. Schema and policy
create table public.todos (
id bigint generated always as identity primary key,
user_id uuid not null default auth.uid() references auth.users on delete cascade,
task text not null,
is_complete boolean not null default false,
created_at timestamptz not null default now()
);
alter table public.todos enable row level security;
create policy "Users manage their own todos" on public.todos
for all to authenticated
using (user_id = auth.uid())
with check (user_id = auth.uid());#4. Codable models and queries
struct Todo: Codable, Identifiable {
var id: Int?
var task: String
var isComplete: Bool = false
enum CodingKeys: String, CodingKey { case id, task, isComplete = "is_complete" }
}
// Select with filters, order and paging
let todos: [Todo] = try await stitchdb.from("todos")
.select("id, task, is_complete")
.eq("is_complete", value: false)
.order("created_at", ascending: false)
.range(from: 0, to: 9)
.execute().value
// Insert (returning the row), update, delete
let created: Todo = try await stitchdb.from("todos").insert(Todo(task: "Ship it")).select().single().execute().value
try await stitchdb.from("todos").update(["is_complete": true]).eq("id", value: created.id!).execute()
try await stitchdb.from("todos").delete().eq("id", value: 1).execute()Writing CodingKeys by hand gets old. Generate the structs instead; you get Codable, Hashable, Sendable types inside enum Database, plus an insert struct per table:
stitchdb gen types swift --linked -o Sources/App/DatabaseTypes.swiftRun stitchdb types --lang swift --watch -o Sources/App/DatabaseTypes.swift while you change the schema, and a renamed column becomes a compile error instead of a crash in production. See Typed clients.
In a SwiftUI view, call these from .task { }:
struct TodoList: View {
@State private var todos: [Todo] = []
var body: some View {
List(todos) { Text($0.task) }
.task {
todos = (try? await stitchdb.from("todos").select().execute().value) ?? []
}
}
}#5. Sign-in
#E-mail and password
try await stitchdb.auth.signUp(email: "[email protected]", password: "a-strong-password")
let session = try await stitchdb.auth.signIn(email: "[email protected]", password: "a-strong-password")
let user = try await stitchdb.auth.user()#OAuth and magic links
try await stitchdb.auth.signInWithOAuth(provider: .github, redirectTo: URL(string: "myapp://login-callback"))
try await stitchdb.auth.signInWithOTP(email: "[email protected]", redirectTo: URL(string: "myapp://login-callback"))On Apple platforms signInWithOAuth opens an ASWebAuthenticationSession and returns signed in. Magic links come back through your URL scheme: add myapp://login-callback to the project's redirect URLs, register the scheme in your target's URL types, and pass the URL to the SDK:
.onOpenURL { url in
Task { try? await stitchdb.auth.session(from: url) }
}Apple is one of the supported OAuth providers, and for native Sign in with Apple the provider settings have a Skip nonce check option aimed at native sign-in libraries. Check the OAuth docs for the provider setup.
#The hosted page
If you do not want to build sign-in screens, open the hosted page:
let session = try await stitchdb.auth.signInWithHostedPage(
redirectTo: URL(string: "myapp://login-callback")!,
view: .signUp // optional: .signIn, .signUp, .magicLink or .forgot
)It handles sign-in, sign-up, e-mail codes, password reset and two-step codes in seven languages. When the project's built-in sign-up check is on, signUp and signInWithOTP solve it on a background task, so you add no captcha widget.
#Watch the auth state
for await (event, session) in stitchdb.auth.authStateChanges {
// .initialSession, .signedIn, .signedOut, .tokenRefreshed, .userUpdated, ...
}Drive your root view from this stream: signed-out users see onboarding, signed-in users see the app.
#6. Realtime
let channel = stitchdb.channel("todos-changes")
let inserts = channel.postgresChange(InsertAction.self, schema: "public", table: "todos")
try await channel.subscribeWithError()
Task { for await insert in inserts { print(insert.record) } }Realtime uses URLSession WebSockets, which exist on Apple platforms. On Linux, Foundation has no WebSocket client, so a Swift server on Linux can use the database, Auth, Storage and Functions but not Realtime.
#7. Files
let bucket = stitchdb.storage.from("avatars")
try await bucket.upload("user-1/avatar.png", data: imageData, options: FileOptions(contentType: "image/png", upsert: true))
let publicURL = try bucket.getPublicURL(path: "user-1/avatar.png") // public buckets
let signedURL = try await bucket.createSignedURL(path: "user-1/avatar.png", expiresIn: 60) // privateCompress photos from PhotosPicker before uploading (JPEG or HEIC at a reasonable size) and serve thumbnails through image resizing URLs.
#8. APNs push
Create an APNs key in your Apple developer account (Certificates, Identifiers & Profiles › Keys), upload the .p8 with its Key ID, Team ID and bundle ID in Studio › Integrations › Push, and turn off Production for builds run from Xcode. Then register the device token:
// AppDelegate: application(_:didRegisterForRemoteNotificationsWithDeviceToken:)
try await stitchdb.push.register(.apns(deviceToken, topics: ["news"]))
// The in-app inbox
let inbox = try await stitchdb.push.inbox(unread: true)
try await stitchdb.push.markAllRead()Sending needs the service role, so it happens in SQL, a trigger, an Edge Function or on your server. A trigger can notify a user the moment an order ships, with no app server in between. See Push notifications.
#9. StoreKit 2 purchases
Checking purchases on the device alone is easy to bypass. Send each verified transaction to StitchDB, which checks it with Apple and stores it for the user:
// After a StoreKit 2 purchase (or for each update in Transaction.updates)
let result = try await product.purchase()
if case .success(let verification) = result { try await stitchdb.purchases.sync(verification) }
if try await stitchdb.purchases.hasEntitlement("pro") { unlockPro() }Set the App Store Server Notifications URL (Version 2) to the address Studio shows, and renewals, refunds and billing problems update the user's status without the app being open. Then guard premium rows in SQL with purchases.has_entitlement(auth.uid(), 'pro'). In-app purchases are included from the Pro plan. The in-app purchase validation guide has the full setup.
#10. Errors
do {
try await stitchdb.from("todos").insert(["task": ""]).execute()
} catch let error as PostgrestError {
print(error.code ?? "", error.message, error.hint ?? "")
} catch let error as AuthError {
print(error.errorCode, error.message)
}A 42501 code means a policy or grant blocked the request. That is usually a missing policy, not a bug in your Swift code.
#When this is not the right fit
- If your users are mostly in North America or Asia and every millisecond counts, EU-only hosting (Germany and Finland) adds latency.
- If you want everything inside Apple's ecosystem, CloudKit needs no third-party account and is built into the platform. It is not Postgres, though, and it is harder to reach from Android or the web.
- The Swift package is installed from GitHub by branch today; pin a commit in
Package.resolvedfor reproducible builds.
#FAQ
#Do I need my own server for an iOS app?
Not for most apps. The app talks to the REST, Auth and Storage APIs with the anon key, policies authorize each request, and Edge Functions cover the few places where you need a secret.
#Where is the session stored on iOS?
In the keychain. Pass options: StitchClientOptions(auth: .init(storage: ...)) to store it somewhere else; that is required on Linux.
#Can my Android and web apps share the same backend?
Yes. One project serves every client. Kotlin, Flutter, JavaScript, Python and C# SDKs talk to the same API, so the same policies apply everywhere.
#How do I test purchases?
Sandbox and TestFlight purchases are stored with environment = 'sandbox' and grant access unless you turn that off in Studio.
Ship your next iOS app on Postgres: create a free project, or compare plans on the pricing page.