In this comprehensive SwiftUI tutorial for iOS developers, we’ll build a fully functional todo application using SwiftUI for iOS development and Supabase as our open-source backend service. By the end of this step-by-step guide, you’ll have created a production-ready iOS app with real-time database synchronization and complete CRUD operations.
We’ll start by creating the complete iOS application using the MVVM architecture pattern, and once our SwiftUI app is running smoothly, we’ll integrate the powerful Supabase backend for data persistence and real-time updates.
You can download the complete project here: Go to Github

What You’ll Build in This SwiftUI Todo App Tutorial
By following this step-by-step SwiftUI tutorial, you’ll create:
- Complete CRUD operations – Create, read, update, and delete todos with SwiftUI and Supabase integration
- Real-time data synchronization – Instant updates across all devices using Supabase’s PostgreSQL database
- Modern iOS interface – Clean, intuitive SwiftUI design following Apple’s Human Interface Guidelines
Understanding MVVM Architecture for SwiftUI App Development
Before diving into our SwiftUI todo app code, let’s understand the Model-View-ViewModel (MVVM) architectural pattern we’ll be implementing throughout this iOS development tutorial.
What is MVVM in iOS Development?
MVVM is a proven architectural pattern that organizes your SwiftUI application into three distinct, maintainable layers:
- Model Layer: Represents your data structures and business logic (Todo entities, User models, and core app data)
- View Layer: The SwiftUI views and UI components that users interact with (TodoListView, AuthView, custom SwiftUI components)
- ViewModel Layer: The crucial bridge between Model and View, managing business logic, state management, and data flow
Why Use MVVM Architecture with SwiftUI?
SwiftUI and MVVM create a powerful combination for iOS app development because:
- Enhanced Reusability: ViewModels can be shared across multiple SwiftUI views, reducing code duplication
- Reactive UI Updates: ViewModels leverage
@Publishedproperties and ObservableObject protocol for automatic UI updates - Clean Separation of Concerns: Business logic remains separate from SwiftUI views, creating more maintainable and readable code
- Superior Testability: Unit test ViewModels independently without UI dependencies, improving code quality and reliability
This MVVM approach will make our Supabase integration cleaner and our SwiftUI todo app more scalable as we add real-time database functionality.
MVVM Flow in Our Todo App
User Interaction → View → ViewModel → Service/API → Database
↑ ↓
← ← ← ← ← ← ← ← ← ← ← ← - User taps a button in the View
- View calls a method on the ViewModel
- ViewModel processes the request and calls the Service
- Service makes API calls to Supabase
- Data flows back through the chain, updating the UI reactively
Essential MVVM Classes for Our SwiftUI Todo App
Our SwiftUI Supabase tutorial will focus on building these core architectural components:
- TodoViewModel: Manages todo list state, handles CRUD operations, and processes real-time database updates from Supabase
- SupabaseService: Our dedicated service layer that handles all API communication with the Supabase backend
Let’s start by building the data models that form the foundation of our iOS app’s architecture, then we’ll implement these ViewModels as they contain the core business logic for our SwiftUI application!
Building Data Models for SwiftUI MVVM Architecture
Before we dive into ViewModels, let’s establish the foundation – our Data Models. In MVVM architecture for iOS development, models are clean data structures that represent the core entities in your SwiftUI application.
Best Practices for SwiftUI Data Models
A well-designed model for iOS app development should be:
- Simple and Clean: Contains only data properties, avoiding complex business logic that belongs in ViewModels
- Identifiable Protocol: Each model instance must be uniquely identifiable for SwiftUI’s list rendering and state management
- Equatable Protocol: Enables SwiftUI’s diffing algorithm to efficiently update the UI when underlying data changes
- Codable Ready: Structured for seamless JSON encoding/decoding with Supabase API responses
These model design principles ensure optimal performance in our SwiftUI todo app and smooth integration with Supabase’s PostgreSQL database.
Our Todo Model
Right click on the Models folder and add a new swift class and write the following code:
import Foundation
struct TodoItemModel: Identifiable, Equatable {
let id = UUID()
var title: String
var isCompleted: Bool = false
}
Let’s break this down:
struct: We use a struct because models are typically value types – simple data containersIdentifiable: This protocol requires anidproperty, allowing SwiftUI to track individual todos in listsEquatable: This lets SwiftUI compare todos efficiently and only update the UI when something actually changeslet id = UUID(): Each todo gets a unique identifier automaticallyvar title&var isCompleted: The actual data we care about – these can change over time
This model is intentionally simple – it focuses purely on representing data, leaving all the business logic (like saving, loading, updating) to the ViewModel. This separation makes our code clean, testable, and easy to understand.
Create the ViewModel for the todos
Before we create a new class we first need to bring some order to our project – so let’s start by creating a Group/Folder called Viewmodels. Inside this folder we create our ToDo viewmodel and call it TodoListViewmodel – then our project structure will look like this:

Inside our viewmodel we will create our business logic – in our case – it will be 3 functions: createToDo(), deleteToDo() and toggleCompletion(). In addition to the 3 functions we will also have an array that holds the todos that we will be displaying in the application.
Inside our TodoListViewmodel class we will start by an empty observable class:
import Foundation
class TodoListViewmodel: ObservableObject {
}Now our viewmodel conforms to ObservableObject, which is SwiftUI’s protocol for reactive data binding.
The next thing we are going to create is an array that conforms to our ToDoModel.swift, and create a init method that appends three todos to our array – an init method is a special function that sets up and initializes a new instance of a class or struct when it’s created.
import Foundation
class TodoListViewmodel: ObservableObject {
@Published var todos: [TodoItemModel] = []
init() {
self.todos.append(TodoItemModel(title: "Buy groceries"))
self.todos.append(TodoItemModel(title: "Finish project"))
self.todos.append(TodoItemModel(title: "Call mom"))
}
}When properties marked with @Published inside this class change, any SwiftUI views that observe this ViewModel will automatically update their UI – this is the magic that makes our todo list refresh in real-time when we add, edit, or delete items.
So far so good – now we need our functions to: Create and Delete.
Create a new Todo
Our createTodo() function is going to be pretty straightforward – the function will take a title for the todo as a parameter and then create a new TodoItemModel and then add that todo to the list of todos:
func addTodo(title: String) {
let newTodo = TodoItemModel(title: title)
todos.append(newTodo)
}Delete a Todo
The next function we need to add is our delete function. This function will take a todo item id as a parameter and when find that id in our list of todos – when it finds this todo it will simply delete it.
And thanks to Swift we have already created such a function for us: removeAll(), so we will just use that:
func deleteTodo(id: UUID) {
todos.removeAll(where: { $0.id == id })
}Enable toggle on a todo
Now we just need to add one last function to our viewmodel – the ability to toggle a todo.
func toggleCompletion(for todo: TodoItemModel) {
if let index = todos.firstIndex(where: { $0.id == todo.id }) {
todos[index].isCompleted.toggle()
}
}What it does:
- Find the todo – Uses
firstIndex(where:)to locate the todo in our array by matching its unique ID - Toggle the status – If found, flips the
isCompletedproperty fromtruetofalse(or vice versa) - Update the UI – Since
todosis a@Publishedproperty, SwiftUI automatically refreshes the interface
Why this approach?
- Safe – The
if letensures we only update if the todo exists - Efficient – Direct array access by index is fast
- Reactive – Changes trigger automatic UI updates
Our viewmodel so far
Here we have our complete viewmodel class that controls how we interact with our todos in the view. Later on we will modify it a bit and add Supabase integration.
import Foundation
class TodoListViewmodel: ObservableObject {
@Published var todos: [TodoItemModel] = [
TodoItemModel(title: "Buy groceries"),
TodoItemModel(title: "Finish project"),
TodoItemModel(title: "Call mom")
]
func addTodo(title: String) {
let newTodo = TodoItemModel(title: title)
todos.append(newTodo)
}
func deleteTodo(id: UUID) {
todos.removeAll(where: { $0.id == id })
}
func toggleCompletion(for todo: TodoItemModel) {
if let index = todos.firstIndex(where: { $0.id == todo.id }) {
todos[index].isCompleted.toggle()
}
}
}
Build the view in SwiftUI
This is where the application comes together of this tutorial – we are going to create our view and use our viewmodel, – time to build the complete application!
When we created our project in the beginning xCode gave us some code and classes to start with, one of those is called ContentView.swift. To keep things simple we will just use that SwiftUI class.
To make this part a bit easier to follow I have put it in three sections and at the end the complete view is.
Connecting View to ViewModel
Now let’s look at how our SwiftUI view connects to the ViewModel:
@StateObject private var todoStore = TodoListViewmodel()
@State private var newTodoTitle = ""@StateObject creates and owns our ViewModel – this tells SwiftUI to keep the todoStore alive for the entire lifetime of this view. When any @Published property in our ViewModel changes, this view will automatically refresh.
@State manages local UI state for our text field. This is perfect for temporary data that only this view cares about, like what the user is currently typing.
The UI Breakdown
var body: some View {
VStack {
// UI components here
}
}The VStack is our main container that arranges all UI elements vertically from top to bottom. This gives us a clean, organized layout structure.
Text("TODO LIST")
.font(Font.title)
.bold()A simple header with title styling – nothing fancy here, just clean UI presentation.
List {
ForEach(todoStore.todos) { todo in
TodoListItemView(todo: todo, toggleCompletion: { todo in
todoStore.toggleCompletion(for: todo)
})
}
.onDelete(perform: deleteTodos)
}The ForEach loops through our todos from the ViewModel. Each time todoStore.todos changes, SwiftUI automatically rebuilds this list.
We’re passing each todo to a separate TodoListItemView component (keeping our code modular), along with a closure that handles toggle completion.
The .onDelete modifier handles swipe-to-delete functionality.
HStack {
TextField("Add new todo", text: $newTodoTitle)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: {
if !newTodoTitle.isEmpty {
todoStore.addTodo(title: newTodoTitle)
newTodoTitle = ""
}
}) {
Image(systemName: "plus.circle.fill")
.font(.title)
}
}The HStack creates a horizontal input area. The TextField uses two-way binding ($newTodoTitle) so typing updates our state automatically.
The Button validates the input isn’t empty, calls our ViewModel method, then clears the text field.
private func deleteTodos(offsets: IndexSet) {
for index in offsets {
todoStore.deleteTodo(id: todoStore.todos[index].id)
}
}This helper function handles the swipe-to-delete action. IndexSet contains the positions of items to delete, so we loop through and call our ViewModel’s delete method for each one.
The complete SwiftUI view
import SwiftUI
struct ContentView: View {
@StateObject private var todoStore = TodoListViewmodel()
@State private var newTodoTitle = ""
var body: some View {
VStack {
Text("TODO LIST")
.font(Font.title)
.bold()
List {
ForEach(todoStore.todos) { todo in
TodoListItemView(todo: todo, toggleCompletion: { todo in
todoStore.toggleCompletion(for: todo)
})
}
.onDelete(perform: deleteTodos)
}
HStack {
TextField("Add new todo", text: $newTodoTitle)
.textFieldStyle(RoundedBorderTextFieldStyle())
Button(action: {
if !newTodoTitle.isEmpty {
todoStore.addTodo(title: newTodoTitle)
newTodoTitle = ""
}
}) {
Image(systemName: "plus.circle.fill")
.font(.title)
}
}
.padding()
}
}
private func deleteTodos(offsets: IndexSet) {
for index in offsets {
todoStore.deleteTodo(id: todoStore.todos[index].id)
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Taking It Further: Adding Supabase Backend
Great! We now have a fully functional todo app with proper MVVM architecture. But what if we want to sync our todos across devices, share them with friends, or simply have them backed up in the cloud?
This is where Supabase comes in – a powerful backend-as-a-service that gives us a PostgreSQL database, real-time subscriptions, authentication, and more, all with just a few lines of code.
In this section, we’ll evolve our local todo app into a cloud-powered application that can sync data across devices in real-time.
What We’ll Add
- Cloud Storage: Todos saved to a PostgreSQL database
- Real-time Sync: Changes appear instantly across all devices
- Offline Support: App works even without internet connection
Setting Up Supabase
1. Create a Supabase Project
First, head over to supabase.com and create a free account. Once logged in:
- Click “New Project”
- Choose your organization
- Enter a project name (e.g., “SwiftUI Todo App”)
- Create a secure database password
- Select a region close to your users
- Click “Create new project”
Your project will take a minute to set up. Once ready, you’ll need two key pieces of information:
- API URL: Found in
Project Settings -> Data API - API Key: Also in
Project Settings ->APIKEYS
2. Database Setup
In your Supabase dashboard you can create a database table in two ways: By clicking or by writing SQL. I will quickly show you the two ways and then in a later tutorial we will dive deeper in Supabase.
Create table by clicking
The first method is probably the most straightforward if you’re completely new. In the left menu you will find “Table editor”, click that. Now on the middle of the screen you will find a button called “Create a table. Now we will just fill out how our table should look like:

Create table using SQL
You can also create the table by using SQL and to do that you just go to the SQL editor in the left menu – it’s right under the “Table editor”. When you are there you simply copy and paste the following SQL into the SQL editor and click run:
CREATE TABLE todos (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
title TEXT NOT NULL,
is_completed BOOLEAN DEFAULT FALSE
);Adding Supabase to Your iOS Project
Now we have created our database and now it’s time to add Supabase to our project. Luckily this is pretty straightforward and Supabase have created an excellent package we will use.
In Xcode, go to File → Add Package Dependencies and add:
https://github.com/supabase/supabase-swiftPrepare TodoItemModel for Supabase
The first thing we need to do is to make sure our model is ready to work with data from Supabase – to do that we will add Codable to pass from JSON to model, CodingKeys to convert names and then two init one for local and one for Supabase.
The complete model will look like this:
import Foundation
struct TodoItemModel: Identifiable, Equatable, Codable {
var id = UUID()
var title: String
var isCompleted: Bool = false
enum CodingKeys: String, CodingKey {
case id
case title
case isCompleted = "is_completed"
}
// For creating new todos locally
init(title: String, isCompleted: Bool = false) {
self.id = UUID()
self.title = title
self.isCompleted = isCompleted
}
// For todos coming from Supabase
init(id: UUID, title: String, isCompleted: Bool) {
self.id = id
self.title = title
self.isCompleted = isCompleted
}
}
Create Supabase service
Now to accurately work with the Supabase database we need to create a Supabase service, we do that by simply creating a new Swift class and call it SupabaseService.swift.
Inside our SupabaseService class we will create a init method that creates our Supabase client. Then we will create one function to fetch all the todos, one function to create a todo, one function to toggle a todo and one function to delete a todo.
Remember add your own Supabase url and key.
You will find the API URL at: Project Settings -> Data API
You will find the API KEY at: Project Settings -> API Keys
They will look something like this:
Supabase url: https://saarqrycgjxqlmmefbsr.supabase.co
Supabase API key: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InNhYXJxcnljZ2p4cWxtbWVmYnNyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDg2MDc3NjQsImV4cCI6MjA2NDE4Mzc2NH0.BNBvtwmgGW6at1_WnFhnZ6FLu8KPFEzr1UQ2C8Y27OM
Supabase init
As with our viewmodel this service will also have a Init function – inside the init we will create a new Supabase client with URL and API key:
class SupabaseService: ObservableObject {
static let shared = SupabaseService()
let client: SupabaseClient
private init() {
// Replace these with your actual Supabase URL and API key
let supabaseURL = URL(string: "YOUR_SUPABASE_PROJECT_URL")!
let supabaseKey = "YOUR_SUPABASE_ANON_KEY"
self.client = SupabaseClient(
supabaseURL: supabaseURL,
supabaseKey: supabaseKey
)
}
}Fetch the todos
The next function we are going to create is the function that fetches all the todos from Supabase:
func fetchTodos() async throws -> [TodoItemModel] {
let response: [TodoItemModel] = try await client
.from("todos")
.select()
.execute()
.value
return response
}Breaking it down:
async throws– This function waits for the database and can handle errors.from("todos")– Points to your “todos” table in the database.select()– Gets all the todo data (like SQL SELECT *).execute()– Actually runs the database query.value– Extracts the JSON data and converts it to[TodoItemModel]
In simple terms: This function asks the database “give me all my todos” and automatically converts the response into an array of TodoItemModel objects that your SwiftUI app can use.
Create a todo
The next function we are going to create is the function that creates a new todo:
func createTodo(_ todo: TodoItemModel) async throws -> TodoItemModel {
let todoData = [
"title": todo.title,
"is_completed": todo.isCompleted
] as [String: Any]
let response: TodoItemModel = try await client
.from("todos")
.insert(todoData)
.select()
.single()
.execute()
.value
return response
}Breaking it down:
todoData– Converts your todo into a format the database understands.insert(todoData)– Adds the new todo to the database.select()– Asks the database to return the saved todo.single()– Expects exactly one todo back (the one we just created)- Returns the saved todo – Complete with database-generated ID and timestamps
In simple terms: This function tells the database “save this new todo for me” and gives you back the official saved version – like getting a receipt after making a purchase!
Toggle a todo
The next function we are making is the function that toggles a todo isCompleted property.
func toggleTodo(_ todo: TodoItemModel) async throws -> TodoItemModel {
let updateData = [
"is_completed": AnyJSON.bool(todo.isCompleted)
]
let response: TodoItemModel = try await client
.from("todos")
.update(updateData)
.eq("id", value: todo.id)
.select()
.single()
.execute()
.value
return response
}Breaking it down:
updateData– Contains only the field we want to change (is_completed).update(updateData)– Tells the database what to change.eq("id", value: todo.id)– Finds the exact todo using its unique ID.select().single()– Gets back the updated todoAnyJSON.bool()– Converts the boolean to Supabase’s expected format
In simple terms: This function tells the database “find this specific todo and flip its completion status” – like checking/unchecking a box, but in the cloud!
Delete a todo
func deleteTodo(id: UUID) async throws {
try await client
.from("todos")
.delete()
.eq("id", value: id)
.execute()
}Breaking it down:
.delete()– Tells the database we want to remove something.eq("id", value: id)– Finds the exact todo using its unique ID.execute()– Actually performs the deletion- No return value – Once deleted, it’s gone forever!
In simple terms: This function tells the database “find this specific todo and delete it completely” – like throwing a piece of paper in the trash, but in the cloud!
Note: This is permanent – once executed, the todo is gone from your database forever.
The complete SupabaseService
The final Supabase service will look like this:
import Foundation
import Supabase
class SupabaseService: ObservableObject {
static let shared = SupabaseService()
let client: SupabaseClient
private init() {
// Replace these with your actual Supabase URL and API key
let supabaseURL = URL(string: "YOUR_SUPABASE_PROJECT_URL")!
let supabaseKey = "YOUR_SUPABASE_ANON_KEY"
self.client = SupabaseClient(
supabaseURL: supabaseURL,
supabaseKey: supabaseKey
)
}
func fetchTodos() async throws -> [TodoItemModel] {
let response: [TodoItemModel] = try await client
.from("todos")
.select()
.execute()
.value
return response
}
func createTodo(_ todo: TodoItemModel) async throws -> TodoItemModel {
let todoData = [
"title": AnyJSON.string(todo.title),
"is_completed": AnyJSON.bool(todo.isCompleted)
]
let response: TodoItemModel = try await client
.from("todos")
.insert(todoData)
.select()
.single()
.execute()
.value
return response
}
func toggleTodo(_ todo: TodoItemModel) async throws -> TodoItemModel {
let updateData = [
"is_completed": AnyJSON.bool(todo.isCompleted)
]
let response: TodoItemModel = try await client
.from("todos")
.update(updateData)
.eq("id", value: todo.id)
.select()
.single()
.execute()
.value
return response
}
func deleteTodo(id: UUID) async throws {
try await client
.from("todos")
.delete()
.eq("id", value: id)
.execute()
}
}Update the viewmodel to use Supabase service
Now we are at the end of this tutorial and the last thing we need to do is to add the supabaseService to our viewmodel – and it should work.
The first thing we are going to do is to add our Supabase service to our viewmodel and then create a new function called: fetchTodos() – this function is going to fetch all the todos from Supabase and pass them to our Todos array. We will replace the code in init with a call to the fetchTodos():
init() {
fetchTodos()
}
func fetchTodos() {
Task {
do {
todos = try await supabaseService.fetchTodos()
} catch {
print("Error loading todos: \(error)")
}
}
}The next thing we are going to do is to replace the code in the other functions with code that uses our Supabase service – we will do the following:
Task – Creates a new async context to handle cloud operations without blocking the UI
do { } – Attempts the cloud operation that might fail
catch { } – Handles network errors, server issues, or other failures gracefully
| Old (Local) | New (Cloud) |
|---|---|
| Instant updates | Waits for cloud confirmation |
| Data lost on app restart | Data persists forever |
| Works offline only | Syncs across devices |
| Simple direct changes | Async operations with error handling |
And here we have the complete code:
import Foundation
class TodoListViewmodel: ObservableObject {
@Published var todos: [TodoItemModel] = []
private let supabaseService = SupabaseService.shared
init() {
fetchTodos()
}
func fetchTodos() {
Task {
do {
todos = try await supabaseService.fetchTodos()
} catch {
print("Error loading todos: \(error)")
}
}
}
func toggleCompletion(for todo: TodoItemModel) {
var updatedTodo = todo
updatedTodo.isCompleted.toggle()
Task {
do {
let serverTodo = try await supabaseService.updateTodo(updatedTodo)
if let index = todos.firstIndex(where: { $0.id == todo.id }) {
todos[index] = serverTodo
}
} catch {
print("Failed to update todo: \(error.localizedDescription)")
}
}
}
func addTodo(title: String) {
let newTodo = TodoItemModel(title: title)
Task {
do {
let createdTodo = try await supabaseService.createTodo(newTodo)
todos.insert(createdTodo, at: 0)
} catch {
print("Failed to add todo: \(error.localizedDescription)")
}
}
}
func deleteTodo(id: UUID) {
Task {
do {
try await supabaseService.deleteTodo(id: id)
todos.removeAll { $0.id == id }
} catch {
print("Failed to delete todo: \(error.localizedDescription)")
}
}
}
}Your SwiftUI Todo App with Supabase is Complete
Congratulations! You’ve successfully built a fully functional SwiftUI todo application with Supabase backend integration. Throughout this comprehensive iOS development tutorial, you’ve learned to implement:
- Modern MVVM Architecture – Clean separation of concerns with scalable ViewModels and data models
- Complete CRUD Operations – Create, read, update, and delete todos with real-time database synchronization
- Supabase Integration – Leveraging PostgreSQL database, authentication, and real-time subscriptions
- Production-Ready iOS App – Offline capability, error handling, and optimized SwiftUI performance
You can download the complete project here: Go to Github