SwiftUI ToDo App Tutorial: Integrating Supabase for Backend Support

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 @Published properties 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 InteractionViewViewModelService/APIDatabase
                    ↑                      ↓
                    ← ← ← ← ← ← ← ← ← ← ← ← 
  1. User taps a button in the View
  2. View calls a method on the ViewModel
  3. ViewModel processes the request and calls the Service
  4. Service makes API calls to Supabase
  5. 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 containers
  • Identifiable: This protocol requires an id property, allowing SwiftUI to track individual todos in lists
  • Equatable: This lets SwiftUI compare todos efficiently and only update the UI when something actually changes
  • let id = UUID(): Each todo gets a unique identifier automatically
  • var 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:

  1. Find the todo – Uses firstIndex(where:) to locate the todo in our array by matching its unique ID
  2. Toggle the status – If found, flips the isCompleted property from true to false (or vice versa)
  3. Update the UI – Since todos is a @Published property, SwiftUI automatically refreshes the interface

Why this approach?

  • Safe – The if let ensures 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:

  1. Click “New Project”
  2. Choose your organization
  3. Enter a project name (e.g., “SwiftUI Todo App”)
  4. Create a secure database password
  5. Select a region close to your users
  6. 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 -> API KEYS

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

Prepare 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 todo
  • AnyJSON.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 updatesWaits for cloud confirmation
Data lost on app restartData persists forever
Works offline onlySyncs across devices
Simple direct changesAsync 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

Scroll to Top