Getting Started

Build a User Management App with Swift and SwiftUI


This tutorial demonstrates how to build a basic user management app. The app authenticates and identifies the user, stores their profile information in the database, and allows the user to log in, update their profile details, and upload a profile photo. The app uses:

Supabase User Management example

Project setup

Before we start building we're going to set up our Database and API. This is as simple as starting a new Project in Supabase and then creating a "schema" inside the database.

Create a project

  1. Create a new project in the Supabase Dashboard.
  2. Enter your project details.
  3. Wait for the new database to launch.

Set up the database schema

Now we are going to set up the database schema. We can use the "User Management Starter" quickstart in the SQL Editor, or you can just copy/paste the SQL from below and run it yourself.

  1. Go to the SQL Editor page in the Dashboard.
  2. Click User Management Starter.
  3. Click Run.

_10
supabase link --project-ref <project-id>
_10
# You can get <project-id> from your project's dashboard URL: https://supabase.com/dashboard/project/<project-id>
_10
supabase db pull

Get the API Keys

Now that you've created some database tables, you are ready to insert data using the auto-generated API. We just need to get the Project URL and anon key from the API settings.

  1. Go to the API Settings page in the Dashboard.
  2. Find your Project URL, anon, and service_role keys on this page.

Building the app

Let's start building the SwiftUI app from scratch.

Create a SwiftUI app in Xcode

Open Xcode and create a new SwiftUI project.

Add the supabase-swift dependency.

Add the https://github.com/supabase/supabase-swift package to your app. For instructions, see the Apple tutorial on adding package dependencies.

Create a helper file to initialize the Supabase client. You need the API URL and the anon key that you copied earlier. These variables will be exposed on the application, and that's completely fine since you have Row Level Security enabled on your database.

Supabase.swift

_10
import Foundation
_10
import Supabase
_10
_10
let supabase = SupabaseClient(
_10
supabaseURL: URL(string: "YOUR_SUPABASE_URL")!,
_10
supabaseKey: "YOUR_SUPABASE_ANON_KEY"
_10
)

Set up a login view

Set up a SwiftUI view to manage logins and sign ups. Users should be able to sign in using a magic link.

AuthView.swift

_66
import SwiftUI
_66
import Supabase
_66
_66
struct AuthView: View {
_66
@State var email = ""
_66
@State var isLoading = false
_66
@State var result: Result<Void, Error>?
_66
_66
var body: some View {
_66
Form {
_66
Section {
_66
TextField("Email", text: $email)
_66
.textContentType(.emailAddress)
_66
.textInputAutocapitalization(.never)
_66
.autocorrectionDisabled()
_66
}
_66
_66
Section {
_66
Button("Sign in") {
_66
signInButtonTapped()
_66
}
_66
_66
if isLoading {
_66
ProgressView()
_66
}
_66
}
_66
_66
if let result {
_66
Section {
_66
switch result {
_66
case .success:
_66
Text("Check your inbox.")
_66
case .failure(let error):
_66
Text(error.localizedDescription).foregroundStyle(.red)
_66
}
_66
}
_66
}
_66
}
_66
.onOpenURL(perform: { url in
_66
Task {
_66
do {
_66
try await supabase.auth.session(from: url)
_66
} catch {
_66
self.result = .failure(error)
_66
}
_66
}
_66
})
_66
}
_66
_66
func signInButtonTapped() {
_66
Task {
_66
isLoading = true
_66
defer { isLoading = false }
_66
_66
do {
_66
try await supabase.auth.signInWithOTP(
_66
email: email,
_66
redirectTo: URL(string: "io.supabase.user-management://login-callback")
_66
)
_66
result = .success(())
_66
} catch {
_66
result = .failure(error)
_66
}
_66
}
_66
}
_66
}

Account view

After a user is signed in, you can allow them to edit their profile details and manage their account.

Create a new view for that called ProfileView.swift.

ProfileView.swift

_96
import SwiftUI
_96
_96
struct ProfileView: View {
_96
@State var username = ""
_96
@State var fullName = ""
_96
@State var website = ""
_96
_96
@State var isLoading = false
_96
_96
var body: some View {
_96
NavigationStack {
_96
Form {
_96
Section {
_96
TextField("Username", text: $username)
_96
.textContentType(.username)
_96
.textInputAutocapitalization(.never)
_96
TextField("Full name", text: $fullName)
_96
.textContentType(.name)
_96
TextField("Website", text: $website)
_96
.textContentType(.URL)
_96
.textInputAutocapitalization(.never)
_96
}
_96
_96
Section {
_96
Button("Update profile") {
_96
updateProfileButtonTapped()
_96
}
_96
.bold()
_96
_96
if isLoading {
_96
ProgressView()
_96
}
_96
}
_96
}
_96
.navigationTitle("Profile")
_96
.toolbar(content: {
_96
ToolbarItem(placement: .topBarLeading){
_96
Button("Sign out", role: .destructive) {
_96
Task {
_96
try? await supabase.auth.signOut()
_96
}
_96
}
_96
}
_96
})
_96
}
_96
.task {
_96
await getInitialProfile()
_96
}
_96
}
_96
_96
func getInitialProfile() async {
_96
do {
_96
let currentUser = try await supabase.auth.session.user
_96
_96
let profile: Profile =
_96
try await supabase
_96
.from("profiles")
_96
.select()
_96
.eq("id", value: currentUser.id)
_96
.single()
_96
.execute()
_96
.value
_96
_96
self.username = profile.username ?? ""
_96
self.fullName = profile.fullName ?? ""
_96
self.website = profile.website ?? ""
_96
_96
} catch {
_96
debugPrint(error)
_96
}
_96
}
_96
_96
func updateProfileButtonTapped() {
_96
Task {
_96
isLoading = true
_96
defer { isLoading = false }
_96
do {
_96
let currentUser = try await supabase.auth.session.user
_96
_96
try await supabase
_96
.from("profiles")
_96
.update(
_96
UpdateProfileParams(
_96
username: username,
_96
fullName: fullName,
_96
website: website
_96
)
_96
)
_96
.eq("id", value: currentUser.id)
_96
.execute()
_96
} catch {
_96
debugPrint(error)
_96
}
_96
}
_96
}
_96
}

Models

In ProfileView.swift, you used 2 model types for deserializing the response and serializing the request to Supabase. Add those in a new Models.swift file.

Models.swift

_23
struct Profile: Decodable {
_23
let username: String?
_23
let fullName: String?
_23
let website: String?
_23
_23
enum CodingKeys: String, CodingKey {
_23
case username
_23
case fullName = "full_name"
_23
case website
_23
}
_23
}
_23
_23
struct UpdateProfileParams: Encodable {
_23
let username: String
_23
let fullName: String
_23
let website: String
_23
_23
enum CodingKeys: String, CodingKey {
_23
case username
_23
case fullName = "full_name"
_23
case website
_23
}
_23
}

Launch!

Now that you've created all the views, add an entry point for the application. This will verify if the user has a valid session and route them to the authenticated or non-authenticated state.

Add a new AppView.swift file.

AppView.swift

_22
import SwiftUI
_22
_22
struct AppView: View {
_22
@State var isAuthenticated = false
_22
_22
var body: some View {
_22
Group {
_22
if isAuthenticated {
_22
ProfileView()
_22
} else {
_22
AuthView()
_22
}
_22
}
_22
.task {
_22
for await state in supabase.auth.authStateChanges {
_22
if [.initialSession, .signedIn, .signedOut].contains(state.event) {
_22
isAuthenticated = state.session != nil
_22
}
_22
}
_22
}
_22
}
_22
}

Update the entry point to the newly created AppView. Run in Xcode to launch your application in the simulator.

Bonus: Profile photos

Every Supabase project is configured with Storage for managing large files like photos and videos.

Add PhotosPicker

Let's add support for the user to pick an image from the library and upload it. Start by creating a new type to hold the picked avatar image:

AvatarImage.swift

_31
import SwiftUI
_31
_31
struct AvatarImage: Transferable, Equatable {
_31
let image: Image
_31
let data: Data
_31
_31
static var transferRepresentation: some TransferRepresentation {
_31
DataRepresentation(importedContentType: .image) { data in
_31
guard let image = AvatarImage(data: data) else {
_31
throw TransferError.importFailed
_31
}
_31
_31
return image
_31
}
_31
}
_31
}
_31
_31
extension AvatarImage {
_31
init?(data: Data) {
_31
guard let uiImage = UIImage(data: data) else {
_31
return nil
_31
}
_31
_31
let image = Image(uiImage: uiImage)
_31
self.init(image: image, data: data)
_31
}
_31
}
_31
_31
enum TransferError: Error {
_31
case importFailed
_31
}

Add PhotosPicker to profile page

ProfileView.swift

_167
+ import PhotosUI
_167
+ import Storage
_167
+ import Supabase
_167
import SwiftUI
_167
_167
struct ProfileView: View {
_167
@State var username = ""
_167
@State var fullName = ""
_167
@State var website = ""
_167
_167
@State var isLoading = false
_167
_167
+ @State var imageSelection: PhotosPickerItem?
_167
+ @State var avatarImage: AvatarImage?
_167
_167
var body: some View {
_167
NavigationStack {
_167
Form {
_167
+ Section {
_167
+ HStack {
_167
+ Group {
_167
+ if let avatarImage {
_167
+ avatarImage.image.resizable()
_167
+ } else {
_167
+ Color.clear
_167
+ }
_167
+ }
_167
+ .scaledToFit()
_167
+ .frame(width: 80, height: 80)
_167
+
_167
+ Spacer()
_167
+
_167
+ PhotosPicker(selection: $imageSelection, matching: .images) {
_167
+ Image(systemName: "pencil.circle.fill")
_167
+ .symbolRenderingMode(.multicolor)
_167
+ .font(.system(size: 30))
_167
+ .foregroundColor(.accentColor)
_167
+ }
_167
+ }
_167
+ }
_167
_167
Section {
_167
TextField("Username", text: $username)
_167
.textContentType(.username)
_167
.textInputAutocapitalization(.never)
_167
TextField("Full name", text: $fullName)
_167
.textContentType(.name)
_167
TextField("Website", text: $website)
_167
.textContentType(.URL)
_167
.textInputAutocapitalization(.never)
_167
}
_167
_167
Section {
_167
Button("Update profile") {
_167
updateProfileButtonTapped()
_167
}
_167
.bold()
_167
_167
if isLoading {
_167
ProgressView()
_167
}
_167
}
_167
}
_167
.navigationTitle("Profile")
_167
.toolbar(content: {
_167
ToolbarItem {
_167
Button("Sign out", role: .destructive) {
_167
Task {
_167
try? await supabase.auth.signOut()
_167
}
_167
}
_167
}
_167
})
_167
+ .onChange(of: imageSelection) { _, newValue in
_167
+ guard let newValue else { return }
_167
+ loadTransferable(from: newValue)
_167
+ }
_167
}
_167
.task {
_167
await getInitialProfile()
_167
}
_167
}
_167
_167
func getInitialProfile() async {
_167
do {
_167
let currentUser = try await supabase.auth.session.user
_167
_167
let profile: Profile =
_167
try await supabase
_167
.from("profiles")
_167
.select()
_167
.eq("id", value: currentUser.id)
_167
.single()
_167
.execute()
_167
.value
_167
_167
username = profile.username ?? ""
_167
fullName = profile.fullName ?? ""
_167
website = profile.website ?? ""
_167
_167
+ if let avatarURL = profile.avatarURL, !avatarURL.isEmpty {
_167
+ try await downloadImage(path: avatarURL)
_167
+ }
_167
_167
} catch {
_167
debugPrint(error)
_167
}
_167
}
_167
_167
func updateProfileButtonTapped() {
_167
Task {
_167
isLoading = true
_167
defer { isLoading = false }
_167
do {
_167
+ let imageURL = try await uploadImage()
_167
_167
let currentUser = try await supabase.auth.session.user
_167
_167
let updatedProfile = Profile(
_167
username: username,
_167
fullName: fullName,
_167
website: website,
_167
+ avatarURL: imageURL
_167
)
_167
_167
try await supabase
_167
.from("profiles")
_167
.update(updatedProfile)
_167
.eq("id", value: currentUser.id)
_167
.execute()
_167
} catch {
_167
debugPrint(error)
_167
}
_167
}
_167
}
_167
_167
+ private func loadTransferable(from imageSelection: PhotosPickerItem) {
_167
+ Task {
_167
+ do {
_167
+ avatarImage = try await imageSelection.loadTransferable(type: AvatarImage.self)
_167
+ } catch {
_167
+ debugPrint(error)
_167
+ }
_167
+ }
_167
+ }
_167
+
_167
+ private func downloadImage(path: String) async throws {
_167
+ let data = try await supabase.storage.from("avatars").download(path: path)
_167
+ avatarImage = AvatarImage(data: data)
_167
+ }
_167
+
_167
+ private func uploadImage() async throws -> String? {
_167
+ guard let data = avatarImage?.data else { return nil }
_167
+
_167
+ let filePath = "\(UUID().uuidString).jpeg"
_167
+
_167
+ try await supabase.storage
_167
+ .from("avatars")
_167
+ .upload(
_167
+ filePath,
_167
+ data: data,
_167
+ options: FileOptions(contentType: "image/jpeg")
_167
+ )
_167
+
_167
+ return filePath
_167
+ }
_167
}

Finally, update your Models.

Models.swift

_13
struct Profile: Codable {
_13
let username: String?
_13
let fullName: String?
_13
let website: String?
_13
let avatarURL: String?
_13
_13
enum CodingKeys: String, CodingKey {
_13
case username
_13
case fullName = "full_name"
_13
case website
_13
case avatarURL = "avatar_url"
_13
}
_13
}

You no longer need the UpdateProfileParams struct, as you can now reuse the Profile struct for both request and response calls.

At this stage you have a fully functional application!