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.

iOS 17+ · Xcode 16+ · SwiftUI Complexity: Beginner Estimated time: 1–2 weekends Updated: May 12, 2026

Prerequisites

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

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.