Supabase
Supabase ↗ support for Skip apps on both iOS and Android.
On iOS, this package re-exports the official supabase-swift ↗ SDK (v2.43+). On Android, the Swift code is transpiled to Kotlin via Skip Lite and calls are forwarded to the community supabase-kt ↗ SDK (v3.4).
Add the dependency to your Package.swift file:
let package = Package( name: "my-package", products: [ .library(name: "MyProduct", targets: ["MyTarget"]), ], dependencies: [ .package(url: "https://source.skip.dev/skip-supabase.git", "0.0.0"..<"2.0.0"), ], targets: [ .target(name: "MyTarget", dependencies: [ .product(name: "SkipSupabase", package: "skip-supabase") ]) ])The SkipSupabase product includes all modules. You can also depend on individual modules:
SkipSupabaseAuth— AuthenticationSkipSupabasePostgREST— Database queriesSkipSupabaseStorage— File storageSkipSupabaseRealtime— Real-time subscriptions (dependency only; no cross-platform wrapper)SkipSupabaseFunctions— Edge Functions (dependency only; no cross-platform wrapper)
Supabase Project Setup
Section titled “Supabase Project Setup”You need a Supabase ↗ project with its URL and anon key from your project’s Settings > API page.
Creating a Client
Section titled “Creating a Client”import SkipSupabase
let client = SupabaseClient( supabaseURL: URL(string: "https://your-project.supabase.co")!, supabaseKey: "your-anon-key")Authentication
Section titled “Authentication”Sign Up and Sign In
Section titled “Sign Up and Sign In”let auth = client.auth
// Sign up with emailtry await auth.signUp(email: "user@example.com", password: "securepassword")
// Sign in with emailtry await auth.signIn(email: "user@example.com", password: "securepassword")
// Sign in with phonetry await auth.signIn(phone: "+15551234567", password: "securepassword")
// Sign in anonymouslytry await auth.signInAnonymously()Sign Out
Section titled “Sign Out”try await auth.signOut() // All sessions (global)try await auth.signOut(scope: .local) // Only this sessiontry await auth.signOut(scope: .others) // All other sessionsAccessing the Current Session
Section titled “Accessing the Current Session”// Non-throwing — returns nil if no sessionif let session = auth.currentSession { print("Access token: \(session.accessToken)") print("Refresh token: \(session.refreshToken)") print("Expires at: \(session.expiresAt)") print("Expires in: \(session.expiresIn) seconds") print("Token type: \(session.tokenType)")
let user = session.user print("User ID: \(user.id)") print("Email: \(user.email ?? "none")") print("Phone: \(user.phone ?? "none")") print("Anonymous: \(user.isAnonymous)") print("Created: \(user.createdAt)")}
// Throwing — throws AuthError.sessionMissing if not signed indo { let session = try auth.session // Use session...} catch { print("No active session")}Refreshing Sessions
Section titled “Refreshing Sessions”let session = try await auth.refreshSession()Updating User Attributes
Section titled “Updating User Attributes”// Update emaillet user = try await auth.update(user: UserAttributes(email: "new@example.com"))
// Update passwordlet user = try await auth.update(user: UserAttributes(password: "newpassword"))
// Update phonelet user = try await auth.update(user: UserAttributes(phone: "+15559876543"))User Properties
Section titled “User Properties”The User object exposes the following properties:
| Property | Type | Description |
|---|---|---|
id | UUID | Unique user identifier |
email | String? | Email address |
phone | String? | Phone number |
role | String? | User role |
aud | String | Audience claim |
isAnonymous | Bool | Whether the user signed in anonymously |
createdAt | Date | Account creation date |
updatedAt | Date | Last update date |
lastSignInAt | Date? | Last sign-in date |
confirmedAt | Date? | Confirmation date |
emailConfirmedAt | Date? | Email confirmation date |
phoneConfirmedAt | Date? | Phone confirmation date |
confirmationSentAt | Date? | Confirmation email sent date |
recoverySentAt | Date? | Recovery email sent date |
emailChangeSentAt | Date? | Email change notification date |
newEmail | String? | Pending new email |
invitedAt | Date? | Invitation date |
actionLink | String? | Action link |
The following auth features are not yet available in the Skip cross-platform wrapper:
- OAuth / Social login (Apple, Google, GitHub, etc.)
- SSO (Single Sign-On)
- OTP (One-Time Password) / Magic Link
- MFA (Multi-Factor Authentication)
- Auth state change listener (
authStateChanges)- User metadata and app metadata
- User identities and factors
Database (PostgREST)
Section titled “Database (PostgREST)”Querying Data
Section titled “Querying Data”struct Country: Codable { var id: Int var name: String var created: Date? = nil var gdp: Decimal? = nil}
// Select all rowslet response: PostgrestResponse<[Country]> = try await client .from("countries") .select() .execute(options: FetchOptions(head: false, count: .exact))let countries = response.valuelet totalCount = response.countCounting Rows
Section titled “Counting Rows”let response: PostgrestResponse<Void> = try await client .from("countries") .select(count: .exact) .execute()print("Total: \(response.count ?? 0)")Inserting Data
Section titled “Inserting Data”// Insert without returning datalet _: PostgrestResponse<Void> = try await client .from("countries") .insert(Country(id: 1, name: "USA")) .execute()
// Insert and return the inserted rowlet response: PostgrestResponse<[Country]> = try await client .from("countries") .insert(Country(id: 2, name: "France"), returning: .representation) .execute(options: FetchOptions(head: false, count: .exact))let inserted = response.value.firstUpdating Data
Section titled “Updating Data”let _: PostgrestResponse<Void> = try await client .from("countries") .update(Country(id: 1, name: "Australia", gdp: Decimal(123.456))) .eq("id", value: 1) .execute()Upserting Data
Section titled “Upserting Data”let _: PostgrestResponse<Void> = try await client .from("countries") .upsert(Country(id: 1, name: "Japan")) .execute()Deleting Data
Section titled “Deleting Data”let _: PostgrestResponse<Void> = try await client .from("countries") .delete() .eq("id", value: 1) .execute()Filters
Section titled “Filters”All filter methods return a chainable builder:
// Equality.eq("column", value) // Equal.neq("column", value) // Not equal
// Comparison.gt("column", value) // Greater than.gte("column", value) // Greater than or equal.lt("column", value) // Less than.lte("column", value) // Less than or equal
// Array membership.in("column", [v1, v2, v3]) // In array.contains("column", [v1]) // Contains values.containedBy("column", [v1]) // Contained by values
// Pattern matching.like("column", pattern: "%search%") // LIKE (case-sensitive).ilike("column", pattern: "%search%") // ILIKE (case-insensitive)
// Null / boolean check.is("column", value: nil) // IS NULL.is("active", value: true) // IS TRUE
// Full-text search.textSearch("column", query: "word", config: "english", type: .plain) // Full-text searchSorting and Pagination
Section titled “Sorting and Pagination”.order("column", ascending: true, nullsFirst: false).limit(10).range(from: 0, to: 9) // 0-based, inclusiveRPC (Remote Procedure Calls)
Section titled “RPC (Remote Procedure Calls)”// Call a function with no parameterslet response: PostgrestResponse<Void> = try await client .rpc("my_function") .execute()let result = String(data: response.data, encoding: .utf8)
// Call a function with string parameterslet response: PostgrestResponse<Void> = try await client .rpc("search_users", params: [ "query": "john", "limit": "10" ]) .execute()Database limitations:
- Queries must use
PostgrestResponse<[T]>(array). Single-valuePostgrestResponse<T>decoding is not yet supported. Use.limit(1)and take the first element.- The
.single()transform is defined but does not yet affect decoding.- RPC parameters must be
[String: String]dictionaries. Codable parameter objects are not yet supported.- The
orfilter (combining multiple filters with OR logic) is not yet available.- CSV export (
csv()), GeoJSON format (geojson()), and EXPLAIN (explain()) are not available.
Storage
Section titled “Storage”Uploading Files
Section titled “Uploading Files”let storage = client.storage
// Upload from Datalet fileData: Data = ...let response = try await storage .from("images") .upload("public/photo.png", data: fileData, options: FileOptions( contentType: "image/png", upsert: false ))print("Uploaded to: \(response.fullPath)")
// Upload from a file URLlet response = try await storage .from("images") .upload("public/photo.png", fileURL: localFileURL, options: FileOptions( contentType: "image/png" ))Downloading Files
Section titled “Downloading Files”let data = try await storage .from("images") .download(path: "public/photo.png")
// Download with image transformationlet data = try await storage .from("images") .download(path: "public/photo.png", options: TransformOptions( width: 200, height: 100, resize: "fill", quality: 80 ))Listing Files
Section titled “Listing Files”let files = try await storage .from("images") .list(path: "public", options: SearchOptions( limit: 100, offset: 0, search: "photo" ))
for file in files { print("\(file.name)")}Public and Signed URLs
Section titled “Public and Signed URLs”let bucket = storage.from("images")
// Public URL (bucket must be public)let publicURL = try bucket.getPublicURL( path: "public/photo.png", download: false)
// Public URL with image transformationlet transformedURL = try bucket.getPublicURL( path: "public/photo.png", download: false, options: TransformOptions(width: 200, height: 100, resize: "fill", quality: 80))
// Signed URL (time-limited access)let signedURL = try await bucket.createSignedURL( path: "public/photo.png", expiresIn: 3600, // seconds download: false, transform: TransformOptions(width: 10, height: 10, resize: "fill", quality: 100))File Operations
Section titled “File Operations”let bucket = storage.from("images")
// Copytry await bucket.copy(from: "public/photo.png", to: "archive/photo.png")
// Move (optionally across buckets)try await bucket.move( from: "public/photo.png", to: "public/renamed.png", options: DestinationOptions(destinationBucket: "other-bucket"))
// Update (replace) — supports both Data and file URLtry await bucket.update("public/photo.png", data: newData, options: FileOptions( contentType: "image/png", upsert: true))
// Deletelet removed = try await bucket.remove(paths: ["public/photo.png"])File Info and Existence
Section titled “File Info and Existence”let bucket = storage.from("images")
// Check if a file existslet exists = try await bucket.exists(path: "public/photo.png")
// Get detailed file metadatalet info: FileObjectV2 = try await bucket.info(path: "public/photo.png")print("Name: \(info.name)")print("Size: \(info.size ?? 0) bytes")print("Created: \(info.createdAt?.description ?? "unknown")")print("ETag: \(info.etag ?? "none")")Signed Upload URLs
Section titled “Signed Upload URLs”Create a pre-signed URL that allows uploading without further authentication:
let bucket = storage.from("images")
// Create a signed upload URLlet signedUpload = try await bucket.createSignedUploadURL( path: "public/upload-target.png", options: CreateSignedUploadURLOptions(upsert: true))print("Upload URL: \(signedUpload.signedURL)")print("Token: \(signedUpload.token)")
// Upload to the signed URLlet response = try await bucket.uploadToSignedURL( "public/upload-target.png", token: signedUpload.token, data: imageData, options: FileOptions(contentType: "image/png"))Multiple Signed URLs
Section titled “Multiple Signed URLs”Create signed download URLs for multiple files at once:
let urls = try await storage.from("images").createSignedURLs( paths: ["public/photo1.png", "public/photo2.png"], expiresIn: 3600)for url in urls { print("Signed URL: \(url)")}Bucket Management
Section titled “Bucket Management”let buckets = try await storage.listBuckets()let bucket = try await storage.getBucket("images")
try await storage.createBucket("uploads", options: BucketOptions( public: true, fileSizeLimit: "10mb", allowedMimeTypes: ["image/*", "application/pdf"]))
try await storage.updateBucket("uploads", options: BucketOptions( public: false))
try await storage.emptyBucket("uploads")try await storage.deleteBucket("uploads")
- File upload/update from file URL reads the file into memory first on Android.
FileObjectV2.contentTypefrominfo()is not populated on Android (the Kotlin SDK exposes it as a lazy computed property that cannot be directly accessed).FileObjectV2.metadatafrominfo()is not populated on Android (JSON metadata conversion is not yet implemented).
Realtime
Section titled “Realtime”The SkipSupabaseRealtime module includes the Kotlin realtime-kt dependency but does not yet provide a cross-platform wrapper API. The native SDKs are available on each platform via conditional compilation (#if SKIP / #if !SKIP).
Edge Functions
Section titled “Edge Functions”The SkipSupabaseFunctions module includes the Kotlin functions-kt dependency but does not yet provide a cross-platform wrapper API.
Architecture
Section titled “Architecture”Module Structure
Section titled “Module Structure”| Module | iOS SDK | Android SDK |
|---|---|---|
| SkipSupabaseCore | supabase-swift | supabase-kt BOM 3.4.1 + ktor-client-okhttp |
| SkipSupabaseAuth | Auth | auth-kt, compose-auth, compose-auth-ui |
| SkipSupabasePostgREST | PostgREST | postgrest-kt |
| SkipSupabaseStorage | Storage | storage-kt |
| SkipSupabaseRealtime | Realtime | realtime-kt |
| SkipSupabaseFunctions | Functions | functions-kt |
| SkipSupabase | All of the above | All of the above |
How It Works
Section titled “How It Works”On iOS, SkipSupabase re-exports the official Supabase Swift SDK directly (@_exported import Supabase). Your code uses the full native API. On Android, wrapper classes in #if SKIP blocks adapt the Swift-style API to calls on the Kotlin supabase-kt SDK.
This bridging is challenging because the Swift and Kotlin Supabase SDKs were designed independently with different API shapes. Key implementation details:
- JSON serialization: A custom
CodableSerializerbridges Swift’sCodableto Kotlin’s serialization, with special handling for Supabase’s ISO 8601 date format. - Generic decoding: Due to Kotlin type erasure, decoding is performed at call sites using
@inline(__always)functions. - Query builder: The PostgREST query builder chain is replicated on Android by wrapping the Kotlin SDK’s builder API.
Contributing
Section titled “Contributing”Please file an issue ↗ if there is a particular API that you need for your project, or if something isn’t working right. Pull requests are welcome at skip-supabase ↗.
Building
Section titled “Building”This project is a Swift Package Manager module that uses the Skip plugin to transpile Swift into Kotlin.
Testing
Section titled “Testing”The module can be tested using the standard swift test command
or by running the test target for the macOS destination in Xcode,
which will run the Swift tests as well as the transpiled
Kotlin JUnit tests in the Robolectric Android simulation environment.
Parity testing can be performed with skip test,
which will output a table of the test results for both platforms.