iOS apps in Swift¶
The Swift package gives an iOS app the library's offline reads, queued writes and file cache through a native Swift API. Its calls read as supabase-swift's do, and supabase-swift stays in the app for Auth, Realtime and Functions.
The full Swift API, with articles on offline behavior, errors and testing, is the Swift API reference.
Requirements¶
- iOS 16 or later, and Xcode 26 or later.
- arm64 simulators only: a simulator build that includes x86_64 (such as Any iOS Simulator Device)
fails to link. Build for a specific simulator, or with
ARCHS=arm64.
Add the package¶
In Xcode, choose File > Add Package Dependencies…, enter
https://github.com/nesfeder/supabase-offline-swift, and add the SupabaseOffline library to your
app's target and SupabaseOfflineTesting to its test target. In a package manifest:
and .product(name: "SupabaseOffline", package: "supabase-offline-swift") in your target's
dependencies. Use .upToNextMinor: before 1.0, a minor release may change the API.
Declare a row¶
A row is a Codable, Sendable type whose keys are the table's columns:
From the sample: TaskRows.swift
/// A row of `tasks` as the server and the cache send it.
nonisolated struct TaskRow: Codable, Sendable {
let id: String
let userID: String
let title: String
let notes: String?
let done: Bool
let createdAt: Date
/// The table's column names.
enum CodingKeys: String, CodingKey {
case id
case userID = "user_id"
case title
case notes
case done
case createdAt = "created_at"
}
}
Make the id on the device: a write whose answer was lost is sent again, and with its id the server
recognizes the row it already has. The sample makes every id with UUID().uuidString.lowercased(),
since Postgres writes a UUID in lower case.
nonisolated matters in an app whose code runs on the main actor by default, as Xcode 26 sets new
apps up. A value type declared there encodes and decodes on the main actor only, and the package
works off it, so declare nonisolated every value type you hand to the package or read back from
it: rows, and the patch bodies, Realtime records and filter values of your own that an actor or a
@Sendable callback uses. Without it, a read that decodes the row, or a write from an actor of your
own, does not compile. The sample's typed patch for a task's text is one:
From the sample: TaskRows.swift
/// The text a task's screen edits, sent as one typed update. A cleared note is written as an
/// explicit `null`, so the server's copy clears too, where a synthesized `Encodable` would leave
/// the column out.
nonisolated struct TaskTextPatch: Encodable, Sendable {
let title: String
let notes: String?
/// The table's column names.
enum CodingKeys: String, CodingKey {
case title
case notes
}
func encode(to encoder: any Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(title, forKey: .title)
try container.encode(notes, forKey: .notes)
}
}
Create the client¶
Create one client for the app and share it. The access-token closure gives it the signed-in user's token; with supabase-swift's Auth, as the sample signs in anonymously:
From the sample: AppModel.swift
// The one client the app has, made at launch and shared: it learns who the user is
// from the token alone, and follows the app's own connectivity signal.
let client = SupabaseOfflineClient(
supabaseURL: url,
supabaseKey: publishableKey,
config: ClientSettings.config(
accessToken: { await session.accessToken() },
networkAvailability: ClientSettings.pathMonitorSignal
)
)
The session's token, which supabase-swift's session refreshes first when it has expired:
From the sample: AnonymousSession.swift
nonisolated func accessToken() async -> String? {
// `session` refreshes an expired token first. With no session (or none that refreshes),
// the request goes with the publishable key alone, which the schema refuses.
try? await auth.session.accessToken
}
The key is the project's publishable key. When nobody is signed in the closure returns nil, and
requests go with the publishable key alone. The sample's ClientSettings.config writes out every
other setting with the default it keeps; see Configuration.
When the user changes¶
The client learns who the user is only from the token the closure returns, so it cannot tell one
user's cached rows and queued writes from another's: clear them with clearLocalData() when the
user signs out, with the client's network turned off from before the sign-out until the next user
is signed in. Sync the user's queued writes first if they should still go; turn the network off;
sign out, so the closure returns nil, clearing even if the sign-out throws; clear; and once the
next user is signed in, turn the network back on, which makes open live reads fetch again with
their token. The sample's Start over does it, discarding the queued writes rather than sending
them, since its anonymous user leaves for good:
From the sample: TaskRepository.swift
/// Starts again as a new anonymous user with no tasks, in this order:
///
/// 1. Photo uploads are held, the one in progress waited for, and this user's queued writes
/// are discarded (a write already being sent is waited for), so none lands after the deletes.
/// 2. Their photo files and rows are deleted from the server, with their token: an anonymous
/// user cannot sign back in to reach them.
/// 3. The client's network is turned off, until the new user is signed in.
/// 4. The app signs out, so the client's token closure returns `nil`.
/// 5. Local data is cleared: the library's, then the photos waiting to upload.
/// 6. The app signs in as a new user, and the network is turned back on: open live reads then
/// fetch again, with the new user's token.
///
/// - Throws: ``SampleFailure/needsNetwork`` while offline, Simulate offline included, when
/// nothing is discarded, deleted or cleared.
func startOver() async throws {
guard canReachNetwork else { throw SampleFailure.needsNetwork }
await pendingPhotos.holdUploads()
defer { pendingPhotos.releaseUploads() }
for mutation in try await client.pendingMutations().first() {
try await client.discardPendingMutation(id: mutation.id)
}
if let userID = session.userID { try await deleteEverything(of: userID) }
try await client.disableNetwork()
await session.signOut()
try await client.clearLocalData()
try pendingPhotos.removeAll()
do {
try await session.signIn()
} catch {
try await client.enableNetwork()
throw error
}
try await client.enableNetwork()
}
Why the network is off: a live read that refreshes fetches again right after the clear, with whatever token the closure returns then, so a clear made while it still returns the outgoing user's token puts that user's rows back for the next one; and a sync that runs between the sign-out and the clear sends the outgoing user's queued writes with the publishable key alone. Clearing after a sign-out that failed is safe with supabase-swift, which ends the session on the device before it calls the server; with an Auth that keeps its session when the sign-out fails, end the session on the device yourself first.
Signing out and back in as the same user needs nothing. If your app reads while nobody is signed in, turn the network back on right after the clear instead, and restart open live reads once the next user signs in. If a session can end without your sign-out code running, clear local data once it has ended, before the next user signs in.
Read¶
From the sample: TaskRepository.swift
/// Asks the server for `query`'s tasks again, as pull to refresh does: the same query as the
/// list's live read, so its answer replaces that query's cached rows and every reader of it
/// sees them. When the server cannot be reached, the saved rows answer, unless they are older
/// than ``refreshMaxAge``; either way the answer says where it came from.
func refreshTasks(_ query: TaskQuery) async throws -> ListProvenance {
let request = client.from(Tables.tasks).select(readStrategy: .networkFirst(maxAge: Self.refreshMaxAge))
let response = try await query.applied(to: request)
.order("created_at", ascending: false)
.execute([TaskRow].self)
return ListProvenance(response)
}
A read asks the server, and answers from the device's copy when the server cannot be reached, unless
it names another read strategy; the sample's pull to refresh names .networkFirst(maxAge:), so a
saved list older than a day is not shown as an answer. Each read is kept on the device under its own
columns, filters, order and range, so a read the app has never made online has nothing to show
offline.
Write¶
From the sample: TaskRepository.swift
/// Adds task `id` (a new one unless given) for the signed-in user, with the device's clock as
/// its creation time, and says what happened: the server stored it, at the `updated_at` it set
/// and gives back, or it waits in the queue on this device.
func addTask(id: String = DeviceID.make(), title: String, notes: String?, needsConnection: Bool = false) async throws -> NewTaskResult {
let row = NewTaskRow(
id: id,
userID: try signedInUserID(),
title: title,
notes: notes,
done: false,
createdAt: .now
)
let outcome = try await write(taskTitle: title) {
try await client.from(Tables.tasks)
.insert(row, mutationStrategy: Self.mutationStrategy(needsConnection: needsConnection))
.select()
.single()
.execute(StoredTaskRow.self)
}
switch outcome {
case .applied(let result):
// A write sent only straight to the server is never put into the device's lists:
// the app puts in the row the server stored, as it would a row Realtime reports.
if needsConnection { try await client.applyServerRecord(table: Tables.tasks, record: result.value) }
return .saved(storedAt: result.value.updatedAt)
case .queued(let pendingMutation):
return .savedOnDevice(pendingMutationID: pendingMutation.id)
}
}
A write returns a MutationOutcome: the server applied it, or it waits in the queue on the device.
Reads show a queued write at once, and the client sends the queue by itself when the network comes
back. Nothing runs when the app returns to the foreground: call syncPendingMutations() then.
Read live¶
A live read yields the rows again whenever a write, a sync or a refresh changes them. In SwiftUI,
iterate it in a .task, which ends it when the view goes away; the sample's root view runs this one
in .task(id: session.userID), so it starts again for each user:
From the sample: TaskRepository.swift
/// Every task of the user, newest first, as the device holds it and as the server refreshes
/// it: the list the other screens share. The task screen picks its task out of it, because
/// each query is cached on its own, and offline a query never fetched has nothing to show.
func taskSnapshots(readStrategy: SequenceReadStrategy = .cacheFirstThenNetwork) -> QuerySnapshotSequence<[TaskRow]> {
client.from(Tables.tasks)
.selectAsSequence(as: [TaskRow].self, readStrategy: readStrategy)
.order("created_at", ascending: false)
}
and the screen's model iterates it, keeping each snapshot's rows:
From the sample: TasksModel.swift
/// Follows every task of the signed-in user, the list the other screens share, until the
/// calling task is canceled. Before there is a user it asks nothing: the schema refuses every
/// request without one.
func observeTasks() async {
rows = nil
guard repository.session.userID != nil else { return }
while !Task.isCancelled {
do {
for try await snapshot in repository.taskSnapshots() {
rows = snapshot.result
}
return
} catch is CancellationError {
return
} catch {
// The shown list reports a failure; both read the same rows. Read again when it is
// worth it, so the list does not stop.
await untilWorthReadingAgain()
}
}
}
Next¶
The Swift API reference has the rest: moving calls from supabase-swift, what is kept, queued and refused offline, telling failures apart, and testing an app against the in-memory client. Every code sample on this page is the iOS sample's own code, which CI builds and tests; the guided tour shows the rest of the API in it. Under the hood says what the package's binary holds and what that means for an app.