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 Database - a Postgres database for storing your user data and Row Level Security so data is protected and users can only access their own information.
  • Supabase Auth - users log in through magic links sent to their email (without having to set up passwords).
  • Supabase Storage - users can upload a profile photo.

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.

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 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

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

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

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

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

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

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!