```html SwiftUI: How to Build iCloud Sync (iOS 17+, 2026)

How to Build iCloud Sync in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: CloudKit Updated: May 11, 2026
TL;DR

Pass cloudKitDatabase: .automatic to ModelConfiguration and Xcode handles the CloudKit container wiring automatically — no manual CKRecord mapping required. Your SwiftData models sync across every signed-in device in seconds.

import SwiftUI
import SwiftData

@main
struct MyApp: App {
    let container: ModelContainer = {
        let schema = Schema([Note.self])
        let config = ModelConfiguration(
            schema: schema,
            cloudKitDatabase: .automatic   // ← the magic line
        )
        return try! ModelContainer(for: schema,
                                   configurations: [config])
    }()

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

Full implementation

The cleanest path to iCloud sync on iOS 17+ is SwiftData backed by CloudKit's private database. You define normal @Model classes, hand ModelConfiguration the cloudKitDatabase: .automatic flag, and SwiftData generates the CloudKit schema on first launch. Conflict resolution uses a last-write-wins strategy by default; for finer control you can observe CKSyncEngine events directly. Before building, enable the iCloud capability in Xcode → Signing & Capabilities → + → iCloud → check CloudKit, then create or select a container identifier (iCloud.com.yourteam.yourapp).

// Note.swift
import SwiftData
import Foundation

@Model
final class Note {
    // CloudKit requires all stored properties to be optional
    // OR have default values — non-optional without defaults will
    // crash at schema migration time.
    var id: UUID
    var title: String
    var body: String
    var isPinned: Bool
    var updatedAt: Date

    init(
        title: String = "",
        body: String = "",
        isPinned: Bool = false
    ) {
        self.id        = UUID()
        self.title     = title
        self.body      = body
        self.isPinned  = isPinned
        self.updatedAt = .now
    }
}

// MyApp.swift
import SwiftUI
import SwiftData
import CloudKit

@main
struct MyApp: App {
    let container: ModelContainer

    init() {
        do {
            let schema = Schema([Note.self])
            // .automatic picks up the first iCloud container
            // listed in your entitlements file.
            let config = ModelConfiguration(
                schema: schema,
                cloudKitDatabase: .automatic
            )
            container = try ModelContainer(
                for: schema,
                configurations: [config]
            )
        } catch {
            fatalError("Failed to create ModelContainer: \(error)")
        }
    }

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

// NoteListView.swift
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 showAdd = false

    var body: some View {
        NavigationStack {
            List {
                ForEach(notes) { note in
                    NavigationLink(destination: NoteDetailView(note: note)) {
                        VStack(alignment: .leading, spacing: 4) {
                            Text(note.title.isEmpty ? "Untitled" : note.title)
                                .font(.headline)
                            Text(note.updatedAt.formatted(.relative(presentation: .named)))
                                .font(.caption)
                                .foregroundStyle(.secondary)
                        }
                    }
                    .accessibilityLabel("\(note.title), updated \(note.updatedAt.formatted())")
                }
                .onDelete(perform: deleteNotes)
            }
            .navigationTitle("iCloud Notes")
            .toolbar {
                ToolbarItem(placement: .primaryAction) {
                    Button("New Note", systemImage: "square.and.pencil") {
                        showAdd = true
                    }
                }
            }
            .sheet(isPresented: $showAdd) {
                AddNoteView()
            }
        }
    }

    private func deleteNotes(at offsets: IndexSet) {
        for index in offsets {
            context.delete(notes[index])
        }
    }
}

// AddNoteView.swift
struct AddNoteView: View {
    @Environment(\.modelContext) private var context
    @Environment(\.dismiss) private var dismiss
    @State private var title = ""
    @State private var body  = ""

    var body: some View {
        NavigationStack {
            Form {
                TextField("Title", text: $title)
                    .accessibilityLabel("Note title")
                TextEditor(text: $body)
                    .frame(minHeight: 120)
                    .accessibilityLabel("Note body")
            }
            .navigationTitle("New Note")
            .navigationBarTitleDisplayMode(.inline)
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Cancel") { dismiss() }
                }
                ToolbarItem(placement: .confirmationAction) {
                    Button("Save") {
                        let note = Note(title: title, body: body)
                        context.insert(note)
                        dismiss()
                    }
                    .disabled(title.isEmpty)
                }
            }
        }
    }
}

// NoteDetailView.swift
struct NoteDetailView: View {
    @Bindable var note: Note

    var body: some View {
        Form {
            TextField("Title", text: $note.title)
                .font(.headline)
                .accessibilityLabel("Note title")
            TextEditor(text: $note.body)
                .frame(minHeight: 200)
                .accessibilityLabel("Note body")
        }
        .navigationTitle("Edit Note")
        .navigationBarTitleDisplayMode(.inline)
        .onChange(of: note.title) { _, _ in note.updatedAt = .now }
        .onChange(of: note.body)  { _, _ in note.updatedAt = .now }
    }
}

#Preview {
    let config = ModelConfiguration(isStoredInMemoryOnly: true)
    let container = try! ModelContainer(for: Note.self, configurations: [config])
    let ctx = container.mainContext
    ctx.insert(Note(title: "Hello CloudKit", body: "Synced across devices!"))
    ctx.insert(Note(title: "Shopping list",  body: "Milk, eggs, coffee"))
    return NoteListView()
        .modelContainer(container)
}

How it works

  1. cloudKitDatabase: .automatic — This single flag tells ModelConfiguration to create a NSPersistentCloudKitContainer under the hood. It reads the first CKContainer identifier from your app's iCloud.entitlements file automatically, so you never write a container ID string in code.
  2. CloudKit schema mirroring — On first launch SwiftData calls initializeCloudKitSchema() internally, pushing your @Model types as CD_Note record types in the CloudKit dashboard. Subsequent model migrations are handled by SwiftData's versioned schemas.
  3. @Query stays reactive — The @Query(sort:order:) macro on notes in NoteListView observes the local SQLite store. When CloudKit delivers remote changes, SwiftData writes them to SQLite and the query automatically re-fires the view update — no NotificationCenter wiring needed.
  4. Conflict strategy via updatedAt — Stamping note.updatedAt = .now inside .onChange handlers gives CloudKit a deterministic field for last-write-wins merges. Whichever device wrote last wins on the next sync round-trip.
  5. In-memory preview container — The #Preview block creates a ModelConfiguration(isStoredInMemoryOnly: true) container, completely bypassing network calls so previews load instantly without hitting CloudKit quotas.

Variants

Sync status indicator with CKSyncEngine notifications

Show a sync badge in your toolbar so users know whether their data has reached iCloud yet. Subscribe to NSPersistentCloudKitContainer events via NSPersistentCloudKitContainer.eventChangedNotification.

import CoreData      // NSPersistentCloudKitContainer lives here
import Combine

@Observable
final class SyncMonitor {
    var isSyncing = false
    private var cancellable: AnyCancellable?

    init() {
        cancellable = NotificationCenter.default
            .publisher(
                for: NSPersistentCloudKitContainer.eventChangedNotification
            )
            .compactMap { $0.userInfo?[NSPersistentCloudKitContainer.eventNotificationUserInfoKey]
                          as? NSPersistentCloudKitContainer.Event }
            .receive(on: DispatchQueue.main)
            .sink { [weak self] event in
                self?.isSyncing = !event.succeeded && event.endDate == nil
            }
    }
}

// In your view:
struct NoteListView: View {
    @State private var sync = SyncMonitor()

    var body: some View {
        NavigationStack {
            // ...existing list...
            .toolbar {
                ToolbarItem(placement: .status) {
                    if sync.isSyncing {
                        ProgressView()
                            .accessibilityLabel("Syncing with iCloud")
                    } else {
                        Image(systemName: "checkmark.icloud")
                            .foregroundStyle(.green)
                            .accessibilityLabel("iCloud sync up to date")
                    }
                }
            }
        }
        .environment(sync)
    }
}

Lightweight key-value sync with NSUbiquitousKeyValueStore

For small settings (theme preference, last-opened tab, feature flags), skip CloudKit entirely and use NSUbiquitousKeyValueStore. It's limited to 1 MB total and 1,024 keys but syncs within seconds. Enable "Key-value storage" under Signing & Capabilities → iCloud, then read/write via NSUbiquitousKeyValueStore.default.set(_:forKey:). Wrap it in an @Observable class and call NSUbiquitousKeyValueStore.default.synchronize() on UIApplication.willResignActiveNotification to push changes before backgrounding.

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement iCloud sync in SwiftUI for iOS 17+.
Use CloudKit via SwiftData ModelConfiguration(cloudKitDatabase: .automatic).
All @Model stored properties must be optional or have defaults.
Add a sync status badge using NSPersistentCloudKitContainer.eventChangedNotification.
Make it accessible (VoiceOver labels on all sync-state indicators).
Add a #Preview with realistic sample data using an in-memory ModelContainer.

In Soarias's Build phase, paste this prompt into the active file context after your @Model definitions — Claude Code will wire the entitlements, container config, and sync monitor in one pass without switching windows.

Related

FAQ

Does this work on iOS 16?

Partially. ModelConfiguration(cloudKitDatabase:) requires iOS 17+; it doesn't exist on iOS 16. If you need iOS 16 support, fall back to NSPersistentCloudKitContainer from CoreData directly. The entitlements setup is identical, but you write NSManagedObject subclasses instead of @Model and manage NSFetchedResultsController manually. The SwiftData wrapper is strictly iOS 17+.

How do I sync data between different users (shared records)?

SwiftData's CloudKit integration only targets the private iCloud database — data accessible only by the signed-in user. For real-time collaboration or sharing between accounts, you must use the raw CloudKit APIs: create a CKShare from an existing CKRecord, call UICloudSharingController to present the share sheet, and handle CKSyncEngine callbacks for incoming shared-zone changes. This is a substantially larger integration and warrants its own dedicated guide.

What's the UIKit / CoreData equivalent?

Use NSPersistentCloudKitContainer in place of NSPersistentContainer. The container reads the same CloudKit entitlements, mirrors your Core Data entities as CloudKit record types, and pushes/pulls changes via a background NSPersistentCloudKitContainer.initializeCloudKitSchema() call. In your AppDelegate set container.viewContext.automaticallyMergesChangesFromParent = true so remote changes propagate to the main context. Functionally equivalent to the SwiftData path above, just more boilerplate.

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

```