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.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight, App Store, and CloudKit production entitlement
- Basic Swift/SwiftUI knowledge — familiarity with
@State,NavigationSplitView, and SwiftData helps - An iCloud-enabled Apple ID for testing CloudKit sync on a real device or simulator
- CloudKit dashboard access (developer.apple.com → CloudKit Console) to inspect records during development
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
- Non-optional SwiftData properties break CloudKit sync. Every
@Modelproperty must be optional or have a default value when using CloudKit. A non-optionalStringwith no default causes silent sync failures that are hard to diagnose. - Large Markdown bodies stall the UI.
AttributedString(markdown:)is synchronous and can block the main thread on notes longer than ~10,000 characters. Move rendering to a background task withTask.detachedand publish the result back via@MainActor. - App Store rejection for missing Privacy Manifest. Apple began enforcing Privacy Manifest requirements in 2024. Missing it — or declaring file-timestamp API use without a valid reason code — leads to an automated rejection without human review. Check the full reason-code list in Apple's documentation before submitting.
- CloudKit schema not deployed to production. In development, CloudKit auto-creates record types. For production, you must explicitly click "Deploy Schema Changes to Production" in CloudKit Console before submitting to App Store review. Failing to do this means users' notes never sync.
- TextEditor doesn't scroll to cursor on iOS keyboard appearance. Wrap
TextEditorin aScrollViewReaderand use.scrollDismissesKeyboard(.interactively)on the outer scroll view to prevent the keyboard covering the active line.
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.