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

How to Build a Notes App in SwiftUI

A Markdown notes app lets users capture, format, and organise text with live preview — the kind of focused writing tool that gets daily use on iPhone and iPad. This guide is for Swift developers who want a production-quality app with CloudKit sync and a one-time purchase unlock on the App Store.

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

Prerequisites

Architecture overview

The app uses SwiftData as the local persistence layer, with CloudKit providing transparent cross-device sync via ModelContainer's built-in CloudKit backend. Views are structured around a three-column NavigationSplitView: a sidebar of tags/folders, a list of matching notes, and a detail editor. State flows down through @Query and up through @Bindable — no manual ObservableObject wrappers needed. The Markdown rendering layer is pure SwiftUI: AttributedString handles basic syntax, while the editor is a plain TextEditor for speed. StoreKit 2 handles the one-time purchase paywall with async/await throughout.

NotesApp/
├── NotesApp.swift            # @main, ModelContainer setup (CloudKit)
├── Models/
│   ├── Note.swift            # @Model — id, title, body, createdAt, updatedAt
│   └── Tag.swift             # @Model — name, color, notes relationship
├── Views/
│   ├── RootView.swift        # NavigationSplitView (sidebar / list / detail)
│   ├── Sidebar/
│   │   └── SidebarView.swift # All Notes, tag list, Trash
│   ├── NoteList/
│   │   └── NoteListView.swift # @Query with sort/filter, searchable
│   ├── Editor/
│   │   ├── EditorView.swift  # TextEditor + toolbar
│   │   └── MarkdownPreview.swift # AttributedString renderer
│   └── Paywall/
│       └── PaywallView.swift # StoreKit 2 purchase sheet
├── Store/
│   └── PurchaseManager.swift # @Observable StoreKit wrapper
├── PrivacyInfo.xcprivacy
└── NotesApp.entitlements     # com.apple.developer.icloud-container-identifiers

Step-by-step

1. Project setup with SwiftData and CloudKit

Create a new Xcode project (iOS App template), tick "Use SwiftData", and enable the iCloud capability with CloudKit. Then wire up the ModelContainer in your app entry point. Using cloudKitContainerIdentifier here automatically backs every @Model object to iCloud.

import SwiftUI
import SwiftData

@main
struct NotesApp: App {
    let container: ModelContainer = {
        let schema = Schema([Note.self, Tag.self])
        let config = ModelConfiguration(
            schema: schema,
            cloudKitDatabase: .automatic   // Uses iCloud container from entitlements
        )
        return try! ModelContainer(for: schema, configurations: config)
    }()

    var body: some Scene {
        WindowGroup {
            RootView()
        }
        .modelContainer(container)
    }
}

2. Data model — Note and Tag

Keep models flat and CloudKit-friendly: no non-optional relationships without defaults, no binary data blobs larger than ~1 MB. CloudKit sync requires every property to be optional or have a default, so title defaults to an empty string.

import SwiftData
import Foundation

@Model
final class Note {
    var id: UUID
    var title: String
    var body: String
    var createdAt: Date
    var updatedAt: Date
    var isPinned: Bool
    var isTrashed: Bool
    @Relationship(deleteRule: .nullify) var tags: [Tag]

    init(title: String = "", body: String = "") {
        self.id = UUID()
        self.title = title
        self.body = body
        self.createdAt = .now
        self.updatedAt = .now
        self.isPinned = false
        self.isTrashed = false
        self.tags = []
    }
}

@Model
final class Tag {
    var id: UUID
    var name: String
    var colorHex: String          // Store color as hex; Color isn't Codable
    @Relationship(inverse: \Note.tags) var notes: [Note]

    init(name: String, colorHex: String = "#3B82F6") {
        self.id = UUID()
        self.name = name
        self.colorHex = colorHex
        self.notes = []
    }
}

3. Note list view with search and sort

@Query handles fetching and live updates automatically. Add .searchable to filter in-memory — SwiftData's predicate-based search works too, but in-memory filtering avoids round-trip latency for small datasets (under a few thousand notes).

import SwiftUI
import SwiftData

struct NoteListView: View {
    @Environment(\.modelContext) private var context
    @Query(sort: \Note.updatedAt, order: .reverse) private var notes: [Note]

    @State private var searchText = ""
    @Binding var selectedNote: Note?

    var filtered: [Note] {
        let active = notes.filter { !$0.isTrashed }
        guard !searchText.isEmpty else { return active }
        let q = searchText.lowercased()
        return active.filter {
            $0.title.lowercased().contains(q) ||
            $0.body.lowercased().contains(q)
        }
    }

    var body: some View {
        List(filtered, selection: $selectedNote) { note in
            NoteRowView(note: note)
                .tag(note)
        }
        .searchable(text: $searchText, prompt: "Search notes")
        .navigationTitle("Notes")
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button("New Note", systemImage: "square.and.pencil") {
                    let note = Note()
                    context.insert(note)
                    selectedNote = note
                }
            }
        }
    }
}

struct NoteRowView: View {
    let note: Note

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            Text(note.title.isEmpty ? "New Note" : note.title)
                .font(.headline)
                .lineLimit(1)
            Text(note.body.prefix(80))
                .font(.subheadline)
                .foregroundStyle(.secondary)
                .lineLimit(2)
            Text(note.updatedAt.formatted(date: .abbreviated, time: .shortened))
                .font(.caption2)
                .foregroundStyle(.tertiary)
        }
        .padding(.vertical, 4)
    }
}

#Preview {
    NoteListView(selectedNote: .constant(nil))
        .modelContainer(for: [Note.self, Tag.self], inMemory: true)
}

4. Markdown editor with live preview

The editor is the core feature: a TextEditor for raw Markdown on one side, and a rendered Text view driven by AttributedString(markdown:) on the other. A segmented picker lets users toggle between Edit, Preview, and Split modes. Autosave fires via .onChange with a debounce so SwiftData isn't hammered on every keystroke.

import SwiftUI
import SwiftData

enum EditorMode: String, CaseIterable {
    case edit = "Edit"
    case preview = "Preview"
    case split = "Split"
}

struct EditorView: View {
    @Bindable var note: Note
    @State private var mode: EditorMode = .split
    @State private var saveTask: Task?

    var body: some View {
        VStack(spacing: 0) {
            Picker("Mode", selection: $mode) {
                ForEach(EditorMode.allCases, id: \.self) { Text($0.rawValue) }
            }
            .pickerStyle(.segmented)
            .padding(.horizontal)
            .padding(.vertical, 8)

            Divider()

            HStack(spacing: 0) {
                if mode == .edit || mode == .split {
                    TextEditor(text: $note.body)
                        .font(.system(.body, design: .monospaced))
                        .padding(8)
                }

                if mode == .split {
                    Divider()
                }

                if mode == .preview || mode == .split {
                    ScrollView {
                        MarkdownPreview(source: note.body)
                            .padding()
                            .frame(maxWidth: .infinity, alignment: .leading)
                    }
                }
            }
        }
        .navigationTitle($note.title)
        .navigationBarTitleDisplayMode(.inline)
        .onChange(of: note.body) { debouncedSave() }
        .onChange(of: note.title) { debouncedSave() }
    }

    private func debouncedSave() {
        saveTask?.cancel()
        saveTask = Task {
            try? await Task.sleep(for: .milliseconds(600))
            guard !Task.isCancelled else { return }
            note.updatedAt = .now
        }
    }
}

struct MarkdownPreview: View {
    let source: String

    var attributed: AttributedString {
        (try? AttributedString(
            markdown: source,
            options: .init(interpretedSyntax: .inlinesOnlyPreservingWhitespace)
        )) ?? AttributedString(source)
    }

    var body: some View {
        Text(attributed)
            .textSelection(.enabled)
    }
}

#Preview {
    NavigationStack {
        EditorView(note: Note(title: "Hello", body: "# Hello\n\nThis is **Markdown**."))
            .modelContainer(for: [Note.self, Tag.self], inMemory: true)
    }
}

5. Tags and sidebar navigation

A NavigationSplitView sidebar lists built-in smart folders (All, Pinned, Trash) plus user-created tags. Selecting a tag filters the note list — pass a Tag? binding down rather than duplicating @Query in multiple places.

import SwiftUI
import SwiftData

struct RootView: View {
    @Query(sort: \Tag.name) private var tags: [Tag]
    @State private var selectedTag: Tag?
    @State private var selectedNote: Note?

    var body: some View {
        NavigationSplitView {
            SidebarView(tags: tags, selectedTag: $selectedTag)
        } content: {
            NoteListView(selectedNote: $selectedNote, filterTag: selectedTag)
        } detail: {
            if let note = selectedNote {
                EditorView(note: note)
            } else {
                ContentUnavailableView("Select a note", systemImage: "note.text")
            }
        }
    }
}

struct SidebarView: View {
    @Environment(\.modelContext) private var context
    let tags: [Tag]
    @Binding var selectedTag: Tag?

    var body: some View {
        List(selection: $selectedTag) {
            Section("Library") {
                Label("All Notes", systemImage: "note.text").tag(Tag?.none)
            }
            Section("Tags") {
                ForEach(tags) { tag in
                    Label(tag.name, systemImage: "tag")
                        .badge(tag.notes.filter { !$0.isTrashed }.count)
                        .tag(Optional(tag))
                }
                .onDelete { offsets in
                    offsets.map { tags[$0] }.forEach { context.delete($0) }
                }
            }
        }
        .navigationTitle("Folders")
        .toolbar {
            ToolbarItem(placement: .primaryAction) {
                Button("Add Tag", systemImage: "plus") {
                    context.insert(Tag(name: "New Tag"))
                }
            }
        }
    }
}

#Preview {
    RootView()
        .modelContainer(for: [Note.self, Tag.self], inMemory: true)
}

6. CloudKit sync status and conflict handling

SwiftData's CloudKit integration is mostly automatic once the container is configured, but you should surface sync status to users and handle the common case where iCloud is signed out. Use CKContainer.default().accountStatus to check availability at launch and show a banner when sync is unavailable.

import SwiftUI
import CloudKit

@Observable
final class CloudKitStatusMonitor {
    var isAvailable = false
    var statusMessage: String = ""

    func checkStatus() async {
        do {
            let status = try await CKContainer.default().accountStatus()
            await MainActor.run {
                switch status {
                case .available:
                    isAvailable = true
                    statusMessage = ""
                case .noAccount:
                    isAvailable = false
                    statusMessage = "Sign in to iCloud in Settings to sync notes."
                case .restricted:
                    isAvailable = false
                    statusMessage = "iCloud access is restricted on this device."
                default:
                    isAvailable = false
                    statusMessage = "iCloud sync is temporarily unavailable."
                }
            }
        } catch {
            await MainActor.run {
                isAvailable = false
                statusMessage = "Could not reach iCloud."
            }
        }
    }
}

// In your RootView, add:
// @State private var ckMonitor = CloudKitStatusMonitor()
// .task { await ckMonitor.checkStatus() }
// .safeAreaInset(edge: .top) {
//     if !ckMonitor.isAvailable && !ckMonitor.statusMessage.isEmpty {
//         Text(ckMonitor.statusMessage)
//             .font(.caption)
//             .padding(8)
//             .frame(maxWidth: .infinity)
//             .background(.yellow.opacity(0.2))
//     }
// }

7. Privacy Manifest (required for App Store)

Apple requires a PrivacyInfo.xcprivacy file in every app that accesses required-reason APIs. A notes app typically accesses the file timestamp API (NSPrivacyAccessedAPICategoryFileTimestamp) when reading note modification dates. Add the file via File → New → Privacy Manifest in Xcode, then declare any API categories your app uses.

<!-- PrivacyInfo.xcprivacy (Property List format) -->
<?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>NSPrivacyAccessedAPITypes</key>
  <array>
    <dict>
      <key>NSPrivacyAccessedAPIType</key>
      <string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array>
        <!-- C617.1: Display file timestamps to the user -->
        <string>C617.1</string>
      </array>
    </dict>
  </array>
  <key>NSPrivacyCollectedDataTypes</key>
  <array/>
  <key>NSPrivacyTracking</key>
  <false/>
</dict>
</plist>

Common pitfalls

Adding monetization: One-time purchase

Use StoreKit 2's Product.purchase() async API to implement a single non-consumable in-app purchase that unlocks the full app — unlimited notes, CloudKit sync, and all editor features. Define the product ID in App Store Connect first, then load it with Product.products(for:) at launch. Persist the unlocked state by listening to Transaction.updates on app start and verifying the latest transaction for your product ID. Store the verified unlock in UserDefaults as a fast-path cache, but always re-verify on launch so users who reinstall on a new device are restored automatically via StoreKit's AppStore.sync(). Avoid storing the unlock state only in SwiftData — it's backed to iCloud and visible to tools that inspect CloudKit records directly.

Shipping this faster with Soarias

Soarias automates the scaffolding steps that typically eat a day before you write a line of product code: it generates the Xcode project with SwiftData and CloudKit entitlements pre-wired, creates the PrivacyInfo.xcprivacy with the correct file-timestamp reason code, sets up fastlane with a Fastfile for TestFlight and App Store uploads, and pre-populates App Store Connect metadata (screenshots, description, keyword field) so your first submission isn't blocked by missing fields.

For an intermediate app like this Notes app, the manual path from zero to first TestFlight build is typically 2–3 days of configuration before real feature work begins. With Soarias, that collapses to under an hour — you open the generated project in Xcode and start on the Markdown editor immediately. The CloudKit schema deployment reminder, Privacy Manifest validation, and fastlane deliver integration alone save most developers a full day of back-and-forth with App Store Connect during the first submission cycle.

Related guides

FAQ

Does this work on iOS 16?

The SwiftData API used here requires iOS 17+. If you need iOS 16 support, replace @Model with a Core Data NSManagedObject subclass and switch to NSPersistentCloudKitContainer directly — the CloudKit sync logic is nearly identical. The #Preview macro also requires Xcode 15+ but the app itself can target iOS 16 with Core Data.

Do I need a paid Apple Developer account to test?

You can run the app on a simulator or a personal-team device without a paid account, but CloudKit sync requires a paid Apple Developer Program membership ($99/year). Without it, the CloudKit container isn't provisioned and the ModelContainer initialiser will throw at runtime. For local-only testing, replace cloudKitDatabase: .automatic with cloudKitDatabase: .none.

How do I add this to the App Store?

Create an app record in App Store Connect, set the bundle ID to match your Xcode project, archive the build in Xcode (Product → Archive), upload via Organiser or fastlane deliver, deploy your CloudKit schema to production in CloudKit Console, complete the App Privacy questionnaire, and submit for review. First review typically takes 24–48 hours. Make sure your Privacy Manifest is included in the archive — Xcode's build log will warn if it's missing.

How do I handle conflicts when the same note is edited on two devices offline?

SwiftData with CloudKit uses a last-write-wins strategy by default — whichever device syncs last overwrites the other. For a notes app this is usually acceptable, but if you need merge semantics (combining edits from both devices), you'll need to implement operational transformation or a CRDT-based body field. A practical middle ground is to store an edit history as an array of timestamped snapshots in a separate @Model and let users restore a previous version from a version history sheet.

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

```