```html How to Build a Budget App in SwiftUI (2026)

How to Build a Budget App in SwiftUI

A budget app tracks income and expenses across user-defined categories, giving people an honest picture of where their money goes each month. This guide is for iOS developers who want a production-ready personal finance app on the App Store — built with SwiftData, Swift Charts, and zero third-party dependencies.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Intermediate Estimated time: 1 week Updated: May 12, 2026

Prerequisites

Architecture overview

The app uses SwiftData as its persistence layer with two @Model types — Transaction and Category — stored in a single ModelContainer injected at the app entry point. Views read live data through @Query (no manual fetch needed), and a lightweight @Observable view model drives the add/edit sheet. Swift Charts renders the monthly breakdown with zero extra dependencies. There is no network layer; all data stays on-device, which simplifies the Privacy Manifest significantly.

BudgetApp/
├── BudgetApp.swift            # @main, ModelContainer setup
├── Models/
│   ├── Transaction.swift      # @Model — amount, date, type, note
│   └── Category.swift         # @Model — name, color, icon
├── Views/
│   ├── ContentView.swift      # TabView (Dashboard / Transactions / Categories)
│   ├── DashboardView.swift    # Summary cards + Charts
│   ├── TransactionListView.swift
│   ├── AddTransactionView.swift  # Sheet form
│   └── CategoryListView.swift
├── ViewModels/
│   └── BudgetViewModel.swift  # @Observable — sheet state, totals
└── Resources/
    └── PrivacyInfo.xcprivacy  # Required for App Store

Step-by-step

1. Project setup

Create a new Xcode project using the iOS App template. Name it BudgetApp, select SwiftUI for the interface, and choose SwiftData as the storage. Xcode 16 scaffolds the container for you — we'll replace the generated model in Step 2. Set the Minimum Deployments target to iOS 17.0.

// BudgetApp.swift
import SwiftUI
import SwiftData

@main
struct BudgetApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
        .modelContainer(for: [Transaction.self, Category.self])
    }
}

2. Define SwiftData models

Two @Model classes form the data layer. Transaction holds every money movement; Category groups them. SwiftData auto-generates the SQLite schema on first launch — no migration code needed at this stage.

// Models/Category.swift
import SwiftData
import SwiftUI

@Model
final class Category {
    var name: String
    var colorHex: String   // store as hex string, e.g. "#FF6B6B"
    var icon: String       // SF Symbol name

    @Relationship(deleteRule: .cascade, inverse: \Transaction.category)
    var transactions: [Transaction] = []

    init(name: String, colorHex: String = "#4A90E2", icon: String = "tag") {
        self.name = name
        self.colorHex = colorHex
        self.icon = icon
    }
}

// Models/Transaction.swift
import SwiftData
import Foundation

enum TransactionType: String, Codable {
    case income, expense
}

@Model
final class Transaction {
    var amount: Double
    var date: Date
    var type: TransactionType
    var note: String
    var category: Category?

    init(
        amount: Double,
        date: Date = .now,
        type: TransactionType,
        note: String = "",
        category: Category? = nil
    ) {
        self.amount = amount
        self.date = date
        self.type = type
        self.note = note
        self.category = category
    }
}

3. Main tab structure and dashboard cards

The root view is a TabView with three tabs. The Dashboard tab immediately shows the user their balance, total income, and total expenses for the current month — the numbers people care about most before they drill into the list.

// Views/ContentView.swift
import SwiftUI

struct ContentView: View {
    var body: some View {
        TabView {
            DashboardView()
                .tabItem { Label("Dashboard", systemImage: "chart.pie.fill") }

            TransactionListView()
                .tabItem { Label("Transactions", systemImage: "list.bullet.rectangle") }

            CategoryListView()
                .tabItem { Label("Categories", systemImage: "tag.fill") }
        }
    }
}

#Preview {
    ContentView()
        .modelContainer(for: [Transaction.self, Category.self], inMemory: true)
}

// Views/DashboardView.swift
import SwiftUI
import SwiftData

struct DashboardView: View {
    @Query private var transactions: [Transaction]

    private var income: Double {
        transactions.filter { $0.type == .income }.reduce(0) { $0 + $1.amount }
    }
    private var expenses: Double {
        transactions.filter { $0.type == .expense }.reduce(0) { $0 + $1.amount }
    }
    private var balance: Double { income - expenses }

    var body: some View {
        NavigationStack {
            ScrollView {
                VStack(spacing: 16) {
                    SummaryCard(title: "Balance", amount: balance,
                                color: balance >= 0 ? .green : .red)
                    HStack(spacing: 12) {
                        SummaryCard(title: "Income", amount: income, color: .blue)
                        SummaryCard(title: "Expenses", amount: expenses, color: .orange)
                    }
                }
                .padding()
            }
            .navigationTitle("Budget")
        }
    }
}

struct SummaryCard: View {
    let title: String
    let amount: Double
    let color: Color

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            Text(title)
                .font(.caption)
                .foregroundStyle(.secondary)
            Text(amount, format: .currency(code: Locale.current.currency?.identifier ?? "USD"))
                .font(.title2.bold())
                .foregroundStyle(color)
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .padding()
        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
    }
}

#Preview {
    DashboardView()
        .modelContainer(for: [Transaction.self, Category.self], inMemory: true)
}

4. Income vs expense entry form (core feature)

The add-transaction sheet is where the core value of the app lives. A segmented picker lets users flip between Income and Expense; a category picker filters to meaningful buckets. The @Observable view model keeps the sheet state clean and separate from the SwiftData query layer.

// ViewModels/BudgetViewModel.swift
import SwiftUI
import Observation

@Observable
final class BudgetViewModel {
    var isAddingTransaction = false
    var draftAmount: String = ""
    var draftNote: String = ""
    var draftType: TransactionType = .expense
    var draftCategory: Category? = nil
    var draftDate: Date = .now

    func reset() {
        draftAmount = ""
        draftNote = ""
        draftType = .expense
        draftCategory = nil
        draftDate = .now
    }
}

// Views/AddTransactionView.swift
import SwiftUI
import SwiftData

struct AddTransactionView: View {
    @Environment(\.modelContext) private var modelContext
    @Environment(\.dismiss) private var dismiss
    @Bindable var vm: BudgetViewModel

    @Query private var categories: [Category]

    var body: some View {
        NavigationStack {
            Form {
                Section("Type") {
                    Picker("Transaction type", selection: $vm.draftType) {
                        Text("Expense").tag(TransactionType.expense)
                        Text("Income").tag(TransactionType.income)
                    }
                    .pickerStyle(.segmented)
                    .listRowBackground(Color.clear)
                }

                Section("Amount") {
                    TextField("0.00", text: $vm.draftAmount)
                        .keyboardType(.decimalPad)
                }

                Section("Details") {
                    DatePicker("Date", selection: $vm.draftDate, displayedComponents: .date)
                    TextField("Note (optional)", text: $vm.draftNote)

                    if categories.isEmpty {
                        Text("Add a category first")
                            .foregroundStyle(.secondary)
                    } else {
                        Picker("Category", selection: $vm.draftCategory) {
                            Text("None").tag(Optional.none)
                            ForEach(categories) { cat in
                                Label(cat.name, systemImage: cat.icon)
                                    .tag(Optional(cat))
                            }
                        }
                    }
                }
            }
            .navigationTitle("New Transaction")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") { save() }
                        .disabled(parsedAmount == nil)
                }
            }
        }
    }

    private var parsedAmount: Double? {
        Double(vm.draftAmount.replacingOccurrences(of: ",", with: "."))
    }

    private func save() {
        guard let amount = parsedAmount else { return }
        let tx = Transaction(
            amount: amount,
            date: vm.draftDate,
            type: vm.draftType,
            note: vm.draftNote,
            category: vm.draftCategory
        )
        modelContext.insert(tx)
        vm.reset()
        dismiss()
    }
}

#Preview {
    let vm = BudgetViewModel()
    return AddTransactionView(vm: vm)
        .modelContainer(for: [Transaction.self, Category.self], inMemory: true)
}

5. Transaction list with swipe-to-delete

@Query keeps the list live with no manual refresh calls. Pass a SortDescriptor to order by date descending. Swipe-to-delete calls modelContext.delete and SwiftData handles cascade deletes automatically based on the relationship rule we defined in Step 2.

// Views/TransactionListView.swift
import SwiftUI
import SwiftData

struct TransactionListView: View {
    @Environment(\.modelContext) private var modelContext
    @State private var vm = BudgetViewModel()

    @Query(sort: \Transaction.date, order: .reverse)
    private var transactions: [Transaction]

    var body: some View {
        NavigationStack {
            Group {
                if transactions.isEmpty {
                    ContentUnavailableView(
                        "No transactions yet",
                        systemImage: "creditcard",
                        description: Text("Tap + to add your first income or expense.")
                    )
                } else {
                    List {
                        ForEach(transactions) { tx in
                            TransactionRow(transaction: tx)
                        }
                        .onDelete(perform: delete)
                    }
                }
            }
            .navigationTitle("Transactions")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button {
                        vm.isAddingTransaction = true
                    } label: {
                        Image(systemName: "plus")
                    }
                }
            }
            .sheet(isPresented: $vm.isAddingTransaction) {
                AddTransactionView(vm: vm)
            }
        }
    }

    private func delete(_ offsets: IndexSet) {
        for index in offsets {
            modelContext.delete(transactions[index])
        }
    }
}

struct TransactionRow: View {
    let transaction: Transaction

    var body: some View {
        HStack {
            Image(systemName: transaction.category?.icon ?? "creditcard")
                .foregroundStyle(transaction.type == .income ? Color.blue : Color.orange)
                .frame(width: 32)

            VStack(alignment: .leading, spacing: 2) {
                Text(transaction.category?.name ?? "Uncategorised")
                    .font(.subheadline.weight(.medium))
                if !transaction.note.isEmpty {
                    Text(transaction.note)
                        .font(.caption)
                        .foregroundStyle(.secondary)
                }
                Text(transaction.date, style: .date)
                    .font(.caption2)
                    .foregroundStyle(.secondary)
            }

            Spacer()

            Text(
                (transaction.type == .income ? "+" : "-") +
                transaction.amount.formatted(.currency(code: Locale.current.currency?.identifier ?? "USD"))
            )
            .font(.subheadline.monospacedDigit())
            .foregroundStyle(transaction.type == .income ? Color.green : Color.primary)
        }
        .padding(.vertical, 2)
    }
}

#Preview {
    TransactionListView()
        .modelContainer(for: [Transaction.self, Category.self], inMemory: true)
}

6. Spending breakdown with Swift Charts

Add a Charts section to DashboardView showing a bar chart of daily expenses for the current month and a sector (donut) chart of spending by category. Import Charts — it's a first-party Apple framework, no SPM dependency needed.

// Add to DashboardView.swift
import Charts

// Inside DashboardView body, after the summary cards:
struct SpendingChartsView: View {
    let transactions: [Transaction]

    private var expensesByCategory: [(name: String, total: Double)] {
        let expenses = transactions.filter { $0.type == .expense }
        var map: [String: Double] = [:]
        for tx in expenses {
            let key = tx.category?.name ?? "Other"
            map[key, default: 0] += tx.amount
        }
        return map.map { (name: $0.key, total: $0.value) }
                  .sorted { $0.total > $1.total }
    }

    private var dailyExpenses: [(day: Date, total: Double)] {
        let cal = Calendar.current
        let now = Date.now
        guard let start = cal.date(from: cal.dateComponents([.year, .month], from: now))
        else { return [] }
        let expenses = transactions.filter {
            $0.type == .expense && $0.date >= start
        }
        var map: [Date: Double] = [:]
        for tx in expenses {
            let day = cal.startOfDay(for: tx.date)
            map[day, default: 0] += tx.amount
        }
        return map.map { (day: $0.key, total: $0.value) }
                  .sorted { $0.day < $1.day }
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 20) {
            if !dailyExpenses.isEmpty {
                Text("This month")
                    .font(.headline)
                Chart(dailyExpenses, id: \.day) { item in
                    BarMark(
                        x: .value("Day", item.day, unit: .day),
                        y: .value("Amount", item.total)
                    )
                    .foregroundStyle(Color.orange.gradient)
                }
                .chartXAxis {
                    AxisMarks(values: .stride(by: .day, count: 7)) {
                        AxisGridLine()
                        AxisValueLabel(format: .dateTime.day())
                    }
                }
                .frame(height: 160)
            }

            if !expensesByCategory.isEmpty {
                Text("By category")
                    .font(.headline)
                Chart(expensesByCategory, id: \.name) { item in
                    SectorMark(
                        angle: .value("Amount", item.total),
                        innerRadius: .ratio(0.55),
                        angularInset: 2
                    )
                    .foregroundStyle(by: .value("Category", item.name))
                    .annotation(position: .overlay) {
                        Text(item.name)
                            .font(.caption2)
                            .foregroundStyle(.white)
                    }
                }
                .frame(height: 220)
            }
        }
        .padding()
        .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16))
        .padding(.horizontal)
    }
}

#Preview {
    SpendingChartsView(transactions: [])
}

7. Privacy Manifest (required for App Store)

Apple requires a PrivacyInfo.xcprivacy file in every app submitted to the App Store. Because BudgetApp stores data only on-device and makes no network calls, the manifest is minimal — but it must be present or your submission will be rejected in automated review.

<!-- Resources/PrivacyInfo.xcprivacy -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
  "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>NSPrivacyTracking</key>
  <false/>
  <key>NSPrivacyTrackingDomains</key>
  <array/>
  <key>NSPrivacyCollectedDataTypes</key>
  <array/>
  <key>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <!-- UserDefaults, used by SwiftData internally -->
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryUserDefaults</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <string>CA92.1</string>
      </array>
    </dict>
  </array>
</dict>
</plist>

// Add the file: File ▸ New ▸ File… ▸ App Privacy (in Resource section)
// Xcode will create a structured editor. Switch to Source if needed
// to paste the raw XML above.

Common pitfalls

Adding monetization: One-time purchase

A one-time purchase ("pay once, own forever") is the simplest monetization model for a personal finance app and resonates well with users who are already thinking carefully about money. Implement it with StoreKit 2: define a Non-Consumable IAP product in App Store Connect (e.g., com.yourapp.budgetapp.unlock), then use Product.products(for:) to fetch the product and product.purchase() to initiate the transaction. Listen to Transaction.updates in an async task on app launch to restore purchases across reinstalls without requiring a "Restore" button tap. Gate premium features — extra categories, CSV export, or iCloud sync — behind a check of Transaction.currentEntitlement(for: productID) != nil. StoreKit 2 handles receipt validation server-side automatically, so no backend is needed.

Shipping this faster with Soarias

Soarias automates the parts of this guide that don't write themselves: it scaffolds the full Xcode project with SwiftData models from a plain-English description, wires in the correct ModelContainer setup at the app entry point, generates the PrivacyInfo.xcprivacy with the right reason codes for your API usage, configures fastlane with match for code signing, and submits the build to App Store Connect — including filling out the required metadata fields and setting the StoreKit non-consumable IAP in "Ready to Submit" state alongside your first version.

For an intermediate project like this budget app, most developers spend three to four hours on project setup, signing configuration, and App Store Connect boilerplate before writing a single line of product code. Soarias compresses that to under ten minutes, leaving the week's effort for the parts that actually differentiate your app — the category design, the charts polish, and the onboarding flow.

Related guides

FAQ

Does this work on iOS 16?

No. The guide uses SwiftData and the @Observable macro, both of which require iOS 17 or later. If you need iOS 16 support, replace SwiftData with Core Data and swap @Observable for @ObservableObject — the view structure stays the same, but the model layer requires more boilerplate. Given that iOS 17 adoption is over 90% as of 2026, targeting iOS 17+ is the right call for a new app.

Do I need a paid Apple Developer account to test?

Not for Simulator testing. You can build and run the app on the iOS Simulator for free. However, you need a paid Apple Developer Program membership ($99/year) to run the app on a physical device, submit to TestFlight, or release on the App Store. The StoreKit sandbox environment for testing IAP also requires a paid account.

How do I add this to the App Store?

Archive the app in Xcode (Product ▸ Archive), upload via Xcode Organizer or xcrun altool, create a new app record in App Store Connect with a bundle ID that matches your project, fill in the metadata (name, description, screenshots for required device sizes), add the non-consumable IAP if applicable, and submit for review. First-time submissions typically take one to three business days to review.

How do I add iCloud sync so users keep data across devices?

Enable the iCloud capability in Xcode (Signing & Capabilities ▸ + Capability ▸ iCloud, tick CloudKit), then change your ModelContainer initialiser to use a ModelConfiguration with cloudKitDatabase: .automatic. SwiftData handles the CloudKit schema and sync automatically. Note that CloudKit sync requires testing on a real device and a paid Apple Developer account — it does not work in the Simulator.

Last reviewed: 2026-05-12 by the Soarias team.

```