How to Build a Birthday Reminder App in SwiftUI
A Birthday Reminder app reads birthdays from the iOS Contacts framework, stores them locally with SwiftData, and fires a local notification on the morning of each birthday. It's ideal for developers learning Contacts permissions, UserNotifications, and SwiftData in a focused, shippable project.
Prerequisites
- Mac with Xcode 16+
- Apple Developer Program ($99/year) — required for TestFlight and App Store
- Basic Swift/SwiftUI knowledge
- A physical iPhone for testing: the iOS Simulator's Contacts store is empty and notifications behave differently than on device
Architecture overview
The app uses a single SwiftData ModelContainer for on-device persistence, a ContactsManager service to pull CNContact birthday data into the store, and a NotificationManager that schedules yearly UNCalendarNotificationTrigger alerts. All views are driven by @Query directly — no separate view model layer is needed at this complexity level.
BirthdayReminder/ ├── Models/Birthday.swift ├── Views/BirthdayListView.swift ├── Views/AddBirthdayView.swift ├── Services/ContactsManager.swift ├── Services/NotificationManager.swift └── PrivacyInfo.xcprivacy
Step-by-step
1. Data model
Define a @Model class with computed helpers so views can sort by next occurrence and show days-until counts without extra logic in the view layer.
import SwiftData; import Foundation
@Model final class Birthday {
var name: String
var date: Date
var contactIdentifier: String?
var notifyDaysBefore: Int = 1
init(name: String, date: Date, contactIdentifier: String? = nil) {
self.name = name; self.date = date; self.contactIdentifier = contactIdentifier
}
var nextBirthday: Date {
var c = Calendar.current.dateComponents([.month, .day], from: date)
c.year = Calendar.current.component(.year, from: .now)
let candidate = Calendar.current.date(from: c) ?? date
return candidate < .now
? Calendar.current.date(byAdding: .year, value: 1, to: candidate) ?? candidate
: candidate
}
var daysUntil: Int {
Calendar.current.dateComponents([.day], from: .now, to: nextBirthday).day ?? 0
}
}
2. Core UI
A NavigationStack list sorted by next occurrence keeps the most pressing birthdays at the top without any manual refresh logic.
import SwiftUI; import SwiftData
struct BirthdayListView: View {
@Query private var birthdays: [Birthday]
@Environment(\.modelContext) private var context
@State private var showAdd = false
var sorted: [Birthday] { birthdays.sorted { $0.nextBirthday < $1.nextBirthday } }
var body: some View {
NavigationStack {
List {
ForEach(sorted) { b in
HStack {
VStack(alignment: .leading) {
Text(b.name).font(.headline)
Text(b.nextBirthday, style: .date).font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(b.daysUntil == 0 ? "🎂 Today!" : "in \(b.daysUntil)d")
.font(.subheadline).foregroundStyle(.orange)
}
}
.onDelete { offsets in offsets.forEach { context.delete(sorted[$0]) } }
}
.navigationTitle("Birthdays")
.toolbar { Button { showAdd = true } label: { Image(systemName: "plus") } }
.sheet(isPresented: $showAdd) { AddBirthdayView() }
}
}
}
3. Import contact birthdays
Request Contacts access at runtime, enumerate contacts with birthday keys, and skip existing entries by checking contactIdentifier to prevent duplicates on re-import.
import Contacts; import SwiftData
final class ContactsManager {
static func importBirthdays(into context: ModelContext, existing: [Birthday]) async throws {
let store = CNContactStore()
guard try await store.requestAccess(for: .contacts) else { return }
let keys: [CNKeyDescriptor] = [
CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor,
CNContactBirthdayKey as CNKeyDescriptor, CNContactIdentifierKey as CNKeyDescriptor
]
let existingIDs = Set(existing.compactMap(\.contactIdentifier))
let req = CNContactFetchRequest(keysToFetch: keys)
try store.enumerateContacts(with: req) { contact, _ in
guard let comps = contact.birthday, let month = comps.month, let day = comps.day,
!existingIDs.contains(contact.identifier) else { return }
let year = comps.year ?? Calendar.current.component(.year, from: .now)
guard let date = Calendar.current.date(
from: DateComponents(year: year, month: month, day: day)) else { return }
let name = "\(contact.givenName) \(contact.familyName)".trimmingCharacters(in: .whitespaces)
context.insert(Birthday(name: name, date: date, contactIdentifier: contact.identifier))
}
try context.save()
}
}
4. Privacy Manifest setup
Add a PrivacyInfo.xcprivacy file to your app target — App Store Connect rejects builds that access Contacts without a properly structured Privacy Manifest.
<?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>NSPrivacyTracking</key>
<false/>
<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
- Testing on Simulator: The iOS Simulator Contacts store is empty by default. Always test the import flow on a real device with contacts that have birthday fields populated.
- Nil birthday year: Many contacts store only month and day —
comps.yearwill benil. Always fall back to the current year or you'll silently produce wrong dates far in the past. - Duplicate imports: Re-running import without deduplication on
contactIdentifierfills SwiftData with copies. Diff against existing entries before every insert. - Notification trigger repeats: Use
UNCalendarNotificationTriggerwithrepeats: trueand only month/day components — omitting year — so the alert fires automatically every year without rescheduling. - App Store rejection: A missing
NSContactsUsageDescriptionstring inInfo.plistcauses automated rejection before human review even begins. Confirm it's present before uploading.
Adding monetization: One-time purchase
Use StoreKit 2 to gate contact sync behind a one-time unlock. Define a non-consumable product in App Store Connect (e.g., com.yourapp.pro), then call Product.purchase() from a paywall sheet. On a successful transaction, persist the unlocked state with @AppStorage("isPro"). The free tier can allow up to five manually entered birthdays; the paid tier removes the cap and enables ContactsManager.importBirthdays. StoreKit 2's Transaction.currentEntitlements async sequence handles restore on reinstall automatically, so you don't need a dedicated "Restore Purchases" button to pass App Review.
Shipping this faster with Soarias
Soarias scaffolds the Birthday Reminder project with Contacts and UserNotifications entitlements pre-wired, generates the PrivacyInfo.xcprivacy automatically based on your declared permission usage, configures fastlane with a Matchfile for code signing, and submits the build to App Store Connect — metadata, screenshots, review notes, and all — in a single command from Claude Code.
For a beginner-complexity app like this one, most developers burn the bulk of a weekend on provisioning profiles, Privacy Manifest XML structure, and App Store Connect submission gotchas rather than product code. Soarias collapses that overhead to under an hour, turning a 1–2 weekend estimate into a single Saturday afternoon build.
Related guides
FAQ
Do I need a paid Apple Developer account?
A free Apple ID lets you sideload the app onto your own device via Xcode, but Contacts and UserNotifications entitlements require a paid Developer Program membership ($99/year) for proper device provisioning. TestFlight distribution and App Store submission both require the paid account as well.
How do I submit this to the App Store?
Archive the app in Xcode via Product → Archive, then distribute through App Store Connect using the Xcode Organizer. You'll need a completed app record with metadata, at least one screenshot per required device size, and a passing build. Upload to TestFlight first to validate on real devices before triggering App Review — it saves time if reviewers flag a crash during testing.
Can I sync birthdays from Contacts automatically in the background?
Yes, after the user grants access once you can re-sync via BGAppRefreshTask. However, the initial permission prompt must appear in the foreground with a clear in-app explanation of why you need contacts access. Apps that request Contacts without visible context are routinely rejected in App Review — always pair the permission call with a visible "Import from Contacts" button or onboarding screen.
Last reviewed: 2026-05-12 by the Soarias team.