How to Build iCloud Sync in SwiftUI
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
-
cloudKitDatabase: .automatic— This single flag tellsModelConfigurationto create aNSPersistentCloudKitContainerunder the hood. It reads the firstCKContaineridentifier from your app's iCloud.entitlements file automatically, so you never write a container ID string in code. -
CloudKit schema mirroring — On first launch SwiftData calls
initializeCloudKitSchema()internally, pushing your@Modeltypes asCD_Noterecord types in the CloudKit dashboard. Subsequent model migrations are handled by SwiftData's versioned schemas. -
@Querystays reactive — The@Query(sort:order:)macro onnotesinNoteListViewobserves the local SQLite store. When CloudKit delivers remote changes, SwiftData writes them to SQLite and the query automatically re-fires the view update — noNotificationCenterwiring needed. -
Conflict strategy via
updatedAt— Stampingnote.updatedAt = .nowinside.onChangehandlers gives CloudKit a deterministic field for last-write-wins merges. Whichever device wrote last wins on the next sync round-trip. -
In-memory preview container — The
#Previewblock creates aModelConfiguration(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
-
Non-optional stored properties without defaults crash at CloudKit schema push.
CloudKit records can always arrive with missing fields (a device running an older app version
wrote the record). Every
@Modelproperty must be optional or have a default value — no exceptions. Avar count: Intwith no default will throw a fatal error on schema initialisation even if you never actually store a nil. - The simulator does not sync between devices. CloudKit sync only works on physical hardware signed into the same Apple ID. Use two real devices for integration testing; the simulator will write to CloudKit but won't receive remote push notifications that trigger pulls from other devices.
-
Forgetting to set
NSUbiquitousContainersin Info.plist for public records. By default, SwiftData's CloudKit integration uses the private database (user-specific). If you want public or shared records you must drop down to raw CloudKit APIs (CKContainer,CKDatabase,CKRecord) — SwiftData does not currently expose the public or shared databases through its CloudKit integration. -
VoiceOver labels on sync-state UI.
A spinning
ProgressView()with no accessibility label reads as "Progress indicator" to VoiceOver. Always add.accessibilityLabel("Syncing with iCloud")so users with assistive technology understand why the app is busy.
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.