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

How to Build a Contact Manager App in SwiftUI

A Contact Manager app lets users augment their system address book with custom fields — relationship notes, deal context, birthdays with alerts — things Apple's built-in Contacts app doesn't support. It's ideal for freelancers, sales professionals, and anyone who needs a personal CRM on iPhone.

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

Prerequisites

Architecture overview

The app has two data layers: CNContactStore reads the system address book (read-only, with user permission), and SwiftData stores your enriched EnhancedContact records that are keyed to the system contact's identifier. Views are driven by a @Query on SwiftData and a ContactsImporter service that merges the two sources. State flows through @Environment(\.modelContext) and a lightweight @Observable ContactsViewModel.

ContactManagerApp/
├── Models/
│   └── EnhancedContact.swift       # SwiftData @Model
├── Services/
│   └── ContactsImporter.swift      # CNContactStore bridge
├── Views/
│   ├── ContactListView.swift
│   ├── ContactDetailView.swift
│   └── AddEnhancementSheet.swift
└── PrivacyInfo.xcprivacy

Step-by-step

1. Data model

Define a SwiftData @Model that mirrors the system contact's stable identifier and layers on custom fields Apple's Contacts app doesn't offer.

import SwiftData
import Foundation

@Model
final class EnhancedContact {
    @Attribute(.unique) var systemIdentifier: String
    var displayName: String
    var relationshipNote: String
    var dealContext: String
    var lastContactedAt: Date?
    var followUpDate: Date?
    var customTags: [String]
    var createdAt: Date

    init(systemIdentifier: String, displayName: String) {
        self.systemIdentifier = systemIdentifier
        self.displayName = displayName
        self.relationshipNote = ""
        self.dealContext = ""
        self.customTags = []
        self.createdAt = .now
    }
}

// In your App entry point:
// .modelContainer(for: EnhancedContact.self)

2. Core UI — contacts list

Build a searchable list that queries SwiftData and displays a follow-up badge when a contact is overdue for outreach.

import SwiftUI
import SwiftData

struct ContactListView: View {
    @Query(sort: \EnhancedContact.displayName) var contacts: [EnhancedContact]
    @State private var searchText = ""
    @State private var showingAdd = false

    var filtered: [EnhancedContact] {
        guard !searchText.isEmpty else { return contacts }
        return contacts.filter { $0.displayName.localizedCaseInsensitiveContains(searchText) }
    }

    var body: some View {
        NavigationStack {
            List(filtered) { contact in
                NavigationLink(destination: ContactDetailView(contact: contact)) {
                    HStack {
                        VStack(alignment: .leading, spacing: 2) {
                            Text(contact.displayName).fontWeight(.medium)
                            if !contact.customTags.isEmpty {
                                Text(contact.customTags.joined(separator: " · "))
                                    .font(.caption).foregroundStyle(.secondary)
                            }
                        }
                        Spacer()
                        if let due = contact.followUpDate, due < .now {
                            Image(systemName: "bell.badge.fill")
                                .foregroundStyle(.orange)
                        }
                    }
                }
            }
            .searchable(text: $searchText)
            .navigationTitle("Contacts")
            .toolbar { Button("Import", systemImage: "person.badge.plus") { showingAdd = true } }
            .sheet(isPresented: $showingAdd) { ContactImportSheet() }
        }
    }
}

3. Enhanced contacts — importing from CNContactStore

Request Contacts permission, fetch system contacts, then upsert them into SwiftData so the user can annotate without duplicating data.

import Contacts
import SwiftData

@MainActor
final class ContactsImporter {
    private let store = CNContactStore()
    private let keysToFetch: [CNKeyDescriptor] = [
        CNContactIdentifierKey as CNKeyDescriptor,
        CNContactGivenNameKey as CNKeyDescriptor,
        CNContactFamilyNameKey as CNKeyDescriptor
    ]

    func importAll(into context: ModelContext) async throws {
        let granted = try await store.requestAccess(for: .contacts)
        guard granted else { throw ImportError.denied }

        let request = CNContactFetchRequest(keysToFetch: keysToFetch)
        var fetched: [CNContact] = []
        try store.enumerateContacts(with: request) { contact, _ in
            fetched.append(contact)
        }

        for cn in fetched {
            let name = [cn.givenName, cn.familyName]
                .filter { !$0.isEmpty }.joined(separator: " ")
            let descriptor = FetchDescriptor(
                predicate: #Predicate { $0.systemIdentifier == cn.identifier }
            )
            if (try? context.fetch(descriptor))?.first == nil {
                context.insert(EnhancedContact(systemIdentifier: cn.identifier, displayName: name))
            }
        }
        try context.save()
    }

    enum ImportError: Error { case denied }
}

4. Privacy Manifest setup

App Store review requires a PrivacyInfo.xcprivacy file declaring your Contacts access and any required-reason API usage — missing this causes rejection at upload time.

<!-- PrivacyInfo.xcprivacy (add via File › New › File › App Privacy) -->
<?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>NSPrivacyAccessedAPICategoryUserDefaults</string>
      <key>NSPrivacyAccessedAPITypeReasons</key>
      <array><string>CA92.1</string></array>
    </dict>
  </array>
  <key>NSPrivacyCollectedDataTypes</key>
  <array>
    <dict>
      <key>NSPrivacyCollectedDataType</key>
      <string>NSPrivacyCollectedDataTypeContacts</string>
      <key>NSPrivacyCollectedDataTypeLinked</key><false/>
      <key>NSPrivacyCollectedDataTypeTracking</key><false/>
      <key>NSPrivacyCollectedDataTypePurposes</key>
      <array><string>NSPrivacyCollectedDataTypePurposeAppFunctionality</string></array>
    </dict>
  </array>
</dict>
</plist>

Common pitfalls

Adding monetization: One-time purchase

Use StoreKit 2's Product.purchase() API to gate the full app behind a single non-consumable in-app purchase. Define the product in App Store Connect as a non-consumable IAP (e.g. com.yourapp.fullaccess), then on launch call Product.products(for:) to fetch it and check Transaction.currentEntitlement(for:) to determine whether the user has already purchased. Free users can see their first 10 imported contacts; purchasing unlocks unlimited contacts and custom tags. Because this is a one-time purchase, there's no subscription to manage — StoreKit 2 handles restoration automatically when users reinstall the app, and you don't need a receipt server.

Shipping this faster with Soarias

Soarias handles the scaffolding (SwiftData model, CNContactStore permission boilerplate, PrivacyInfo.xcprivacy with correct Contacts reason codes), wires up fastlane lanes for TestFlight builds, and generates the required App Store screenshots at every device size — the three areas where intermediate-complexity apps lose the most setup time. The StoreKit non-consumable product ID is pre-configured in the generated StoreKitConfig.storekit testing file, so you can verify purchase flow before submitting.

For an intermediate app like this, most developers spend 2–3 days on project configuration, privacy declarations, and ASC metadata before writing a line of product code. With Soarias that overhead compresses to under an hour, leaving the full week for the actual contact-enrichment features that differentiate your app.

Related guides

FAQ

Do I need a paid Apple Developer account?

Yes. A free account lets you sideload to your own device, but TestFlight distribution and App Store submission both require the $99/year Apple Developer Program membership. You'll also need it to create the non-consumable IAP product in App Store Connect.

How do I submit this to the App Store?

Archive the app in Xcode (Product › Archive), then use the Xcode Organizer to upload to App Store Connect. From there, complete your app's metadata (description, screenshots, privacy labels), attach the non-consumable IAP, and submit for review. Apple typically reviews within 24–48 hours for new apps.

Can I sync enhanced contact data across a user's devices?

Yes — enable CloudKit in your SwiftData ModelContainer by passing a ModelConfiguration with cloudKitDatabase: .automatic. This syncs your custom fields (notes, tags, follow-up dates) across all devices signed in to the same iCloud account without any additional server infrastructure. Note that system contacts already sync via iCloud Contacts, so only your enrichment data needs to go through CloudKit.

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

```