Push notifications are the single most effective re-engagement tool for iOS apps. A well-timed notification can double your Day 7 retention. A poorly implemented one gets your app uninstalled. This tutorial covers everything you need to implement notifications in a SwiftUI app — local scheduling, APNs remote delivery, rich media, actionable buttons, deep linking, and the production gotchas that cost me weeks of debugging across five shipped apps.
How iOS Notifications Actually Work
Before writing any code, you need to understand the notification stack. iOS has a unified notification system managed by the UserNotifications framework. Whether a notification is local (triggered by your app on-device) or remote (pushed from a server via APNs), it flows through the same pipeline:
- Your app requests authorization — the user sees a system prompt asking to allow notifications. They can grant full permission, deny it, or (since iOS 12) you can use provisional authorization to skip the prompt entirely.
- A notification is created — either locally by your app using
UNUserNotificationCenter, or remotely by your server sending a payload to APNs. - iOS decides how to display it — based on the user's Focus mode, notification settings, and whether your app is in the foreground. If the app is foregrounded, notifications are silently suppressed unless you explicitly handle them.
- The user interacts — tapping the notification opens your app. If you defined custom actions, the user can respond without opening the app at all.
The key class is UNUserNotificationCenter. It is the single entry point for requesting permission, scheduling local notifications, and handling notification responses. You will use it for everything.
Notification Types Compared
iOS supports four distinct notification types. Each serves a different purpose and has different requirements. Here is the complete comparison:
| Type | Trigger | Requires Server | Shows Alert | Use Case |
|---|---|---|---|---|
| Local | Time, calendar, or location | No | Yes | Reminders, timers, streak nudges |
| Remote (Alert) | APNs payload from server | Yes | Yes | Messages, news, social updates |
| Silent | APNs with content-available | Yes | No | Background data sync, prefetching |
| Rich (with Service Extension) | APNs with mutable-content | Yes | Yes (with media) | Image previews, encrypted content, modified alerts |
Most apps need at least local and remote alert notifications. If you are building anything with real-time content — chat, social feeds, marketplace updates — you will need all four.
Step 1: Requesting Notification Permission
The first thing your app must do is request authorization from the user. This triggers the system permission dialog. You only get one shot at this — if the user denies, you cannot show the dialog again. The user has to manually enable notifications in Settings. This is why when you ask matters as much as how.
// NotificationManager.swift
import UserNotifications
import UIKit
@MainActor
final class NotificationManager: ObservableObject {
@Published var isAuthorized = false
@Published var authorizationStatus: UNAuthorizationStatus = .notDetermined
static let shared = NotificationManager()
private init() {}
/// Request full notification authorization.
/// Call this AFTER the user understands why notifications matter
/// (e.g., after onboarding, not on first launch).
func requestAuthorization() async {
do {
let options: UNAuthorizationOptions = [
.alert,
.badge,
.sound,
.providesAppNotificationSettings
]
let granted = try await UNUserNotificationCenter
.current()
.requestAuthorization(options: options)
isAuthorized = granted
authorizationStatus = granted ? .authorized : .denied
if granted {
// Register for remote notifications on the main thread
UIApplication.shared.registerForRemoteNotifications()
}
} catch {
print("Notification authorization failed: \(error.localizedDescription)")
}
}
/// Check current authorization status without prompting.
func checkCurrentStatus() async {
let settings = await UNUserNotificationCenter
.current()
.notificationSettings()
authorizationStatus = settings.authorizationStatus
isAuthorized = settings.authorizationStatus == .authorized
}
}Timing tip: Never request notification permission on first app launch. Users who have not experienced your app's value will instinctively tap "Don't Allow." Instead, ask after the user completes onboarding, achieves their first milestone, or triggers a feature that naturally benefits from notifications (e.g., setting a reminder). Apps that defer the permission ask see 40-60% higher opt-in rates.
Authorization Options Explained
The UNAuthorizationOptions you pass to requestAuthorization determine what your notifications can do. Here is every option and when to use it:
| Option | What It Enables | When to Use |
|---|---|---|
.alert | Banner and lock screen display | Almost always — this is the visible notification |
.badge | App icon badge number | Messaging apps, task lists, unread counts |
.sound | Plays notification sound | Most apps — but respect Do Not Disturb |
.criticalAlert | Bypasses Do Not Disturb and mute switch | Health/safety apps only — requires Apple entitlement |
.provisional | Delivers quietly without prompting | News apps, low-priority updates (see section below) |
.providesAppNotificationSettings | Shows "Configure in App" button in Settings | Any app with granular notification preferences |
.carPlay | Display on CarPlay | Navigation and communication apps |
.timeSensitive | Breaks through Focus and scheduled summary | Delivery updates, ride-sharing, urgent reminders |
For most indie apps, [.alert, .badge, .sound] is the right combination. Add.providesAppNotificationSettings if your app has an in-app notification preferences screen — it adds a "Configure in App" button in the system Settings, which is a nice touch.
Step 2: Scheduling Local Notifications
Local notifications are the simplest type — they do not require a server, APNs certificates, or any backend infrastructure. Your app schedules them on-device, and iOS delivers them at the specified time. They are perfect for reminders, streaks, timers, and habit-building nudges.
// NotificationManager+Local.swift
import UserNotifications
extension NotificationManager {
/// Schedule a time-based local notification.
func scheduleLocalNotification(
id: String,
title: String,
body: String,
timeInterval: TimeInterval,
repeats: Bool = false,
categoryIdentifier: String? = nil,
userInfo: [String: Any] = [:]
) async throws {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
content.userInfo = userInfo
if let categoryIdentifier {
content.categoryIdentifier = categoryIdentifier
}
let trigger = UNTimeIntervalNotificationTrigger(
timeInterval: timeInterval,
repeats: repeats
)
let request = UNNotificationRequest(
identifier: id,
content: content,
trigger: trigger
)
try await UNUserNotificationCenter
.current()
.add(request)
}
/// Schedule a calendar-based notification (e.g., every day at 9 AM).
func scheduleDailyNotification(
id: String,
title: String,
body: String,
hour: Int,
minute: Int
) async throws {
let content = UNMutableNotificationContent()
content.title = title
content.body = body
content.sound = .default
var dateComponents = DateComponents()
dateComponents.hour = hour
dateComponents.minute = minute
let trigger = UNCalendarNotificationTrigger(
dateMatching: dateComponents,
repeats: true
)
let request = UNNotificationRequest(
identifier: id,
content: content,
trigger: trigger
)
try await UNUserNotificationCenter
.current()
.add(request)
}
/// Cancel a specific pending notification.
func cancelNotification(id: String) {
UNUserNotificationCenter.current()
.removePendingNotificationRequests(
withIdentifiers: [id]
)
}
/// Cancel all pending notifications.
func cancelAllNotifications() {
UNUserNotificationCenter.current()
.removeAllPendingNotificationRequests()
}
}Here is how you would use this from a SwiftUI view — for example, letting the user set a daily reminder:
// ReminderSettingsView.swift
import SwiftUI
struct ReminderSettingsView: View {
@StateObject private var notificationManager = NotificationManager.shared
@State private var reminderTime = Date()
@State private var isReminderEnabled = false
var body: some View {
Form {
Toggle("Daily Reminder", isOn: $isReminderEnabled)
.onChange(of: isReminderEnabled) { _, enabled in
Task {
if enabled {
let components = Calendar.current.dateComponents(
[.hour, .minute],
from: reminderTime
)
try? await notificationManager.scheduleDailyNotification(
id: "daily-reminder",
title: "Time to check in",
body: "Your streak is waiting. Open the app to keep it going.",
hour: components.hour ?? 9,
minute: components.minute ?? 0
)
} else {
notificationManager.cancelNotification(id: "daily-reminder")
}
}
}
if isReminderEnabled {
DatePicker(
"Reminder Time",
selection: $reminderTime,
displayedComponents: .hourAndMinute
)
}
}
}
}Important: iOS limits each app to 64 scheduled local notifications. If you try to schedule more, the system silently drops the extras. For apps that need many future notifications (e.g., a habit tracker with per-habit reminders), keep a count and prioritize the most important ones.
Step 3: Registering for Remote Notifications (APNs)
Remote notifications require a server to send payloads through Apple Push Notification service (APNs). The flow is: your app registers with APNs, receives a device token, sends that token to your backend, and then your backend uses the token to push notifications to that specific device.
3a. Xcode Capabilities
Open your Xcode project, select your app target, go to Signing & Capabilities, and click + Capability. Add Push Notifications. Then addBackground Modes and check Remote notifications. Your entitlements file should now include:
<key>aps-environment</key>
<string>development</string>
<!-- Background Modes -->
<key>UIBackgroundModes</key>
<array>
<string>remote-notification</string>
</array>3b. Handling the Device Token
When your app calls UIApplication.shared.registerForRemoteNotifications(), APNs returns a unique device token. You must send this token to your backend so it can address notifications to this device. The token can change — after app reinstall, device restore, or OS update — so you should register it every time the app launches.
// AppDelegate.swift (or your App struct with UIApplicationDelegateAdaptor)
import UIKit
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
// Convert token to hex string
let token = deviceToken.map {
String(format: "%02.2hhx", $0)
}.joined()
print("APNs device token: \(token)")
// Send token to your backend
Task {
await sendTokenToServer(token)
}
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
// This fires on Simulator (APNs not available)
// and when there is no internet connection
print("Failed to register for remote notifications: \(error.localizedDescription)")
}
private func sendTokenToServer(_ token: String) async {
// Store in your backend — e.g., Supabase, Firebase, your own API
// Associate the token with the current user ID
do {
try await supabase
.from("device_tokens")
.upsert([
"user_id": currentUserId,
"token": token,
"platform": "ios",
"updated_at": ISO8601DateFormatter().string(from: Date())
])
.execute()
} catch {
print("Failed to save device token: \(error)")
}
}
}
// In your SwiftUI App struct:
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}Simulator note: APNs device token registration does not work in the iOS Simulator. The
didFailToRegisterForRemoteNotificationsWithErrorcallback will fire every time. You must test remote notifications on a physical device. Local notifications, however, work fine in the Simulator.
3c. APNs Authentication: Key vs Certificate
To send notifications from your server to APNs, you need to authenticate. Apple offers two methods:
- Token-based authentication (.p8 key) — Recommended. One key works for all your apps under the same team. The key never expires (unless you revoke it). Create it in the Apple Developer Portal under Keys. This is what most backend services (Supabase Edge Functions, Firebase Cloud Messaging, AWS SNS) expect.
- Certificate-based authentication (.p12) — The legacy approach. Each certificate is tied to a single app and expires after one year. You have to regenerate and redeploy annually. Avoid this unless your backend specifically requires it.
Step 4: Handling Notification Tap and Deep Linking
When a user taps a notification, your app needs to respond — typically by navigating to the relevant screen. This is handled by conforming to UNUserNotificationCenterDelegate. You must set the delegate early, ideally in your AppDelegate's application(_:didFinishLaunchingWithOptions:).
// AppDelegate+Notifications.swift
import UserNotifications
extension AppDelegate: UNUserNotificationCenterDelegate {
func setupNotifications() {
UNUserNotificationCenter.current().delegate = self
}
/// Called when a notification is delivered while the app is in the foreground.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification
) async -> UNNotificationPresentationOptions {
let userInfo = notification.request.content.userInfo
// Decide whether to show the notification while app is open.
// For chat apps, show it if the user is NOT on the relevant chat screen.
// For most apps, showing a banner is the right call.
return [.banner, .badge, .sound]
}
/// Called when the user taps a notification or interacts with an action button.
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse
) async {
let userInfo = response.notification.request.content.userInfo
let actionIdentifier = response.actionIdentifier
// Handle default tap (user tapped the notification itself)
if actionIdentifier == UNNotificationDefaultActionIdentifier {
handleNotificationTap(userInfo: userInfo)
}
// Handle custom actions
if actionIdentifier == "MARK_COMPLETE" {
await handleMarkComplete(userInfo: userInfo)
}
if actionIdentifier == "REPLY" {
if let textResponse = response as? UNTextInputNotificationResponse {
await handleReply(
text: textResponse.userText,
userInfo: userInfo
)
}
}
}
private func handleNotificationTap(userInfo: [AnyHashable: Any]) {
// Extract deep link info from the payload
guard let screen = userInfo["screen"] as? String else { return }
switch screen {
case "chat":
if let chatId = userInfo["chatId"] as? String {
DeepLinkRouter.shared.navigate(to: .chat(id: chatId))
}
case "profile":
DeepLinkRouter.shared.navigate(to: .profile)
case "settings":
DeepLinkRouter.shared.navigate(to: .settings)
default:
break
}
}
private func handleMarkComplete(userInfo: [AnyHashable: Any]) async {
guard let taskId = userInfo["taskId"] as? String else { return }
// Mark the task as complete in your backend
try? await supabase
.from("tasks")
.update(["completed": true])
.eq("id", value: taskId)
.execute()
}
private func handleReply(
text: String,
userInfo: [AnyHashable: Any]
) async {
guard let chatId = userInfo["chatId"] as? String else { return }
// Send the reply to your backend
try? await supabase
.from("messages")
.insert([
"chat_id": chatId,
"body": text,
"sender_id": currentUserId
])
.execute()
}
}Deep Link Router for SwiftUI
To navigate your SwiftUI app from a notification tap, use an observable router that your views react to:
// DeepLinkRouter.swift
import SwiftUI
enum DeepLink: Hashable {
case chat(id: String)
case profile
case settings
case task(id: String)
}
@MainActor
final class DeepLinkRouter: ObservableObject {
static let shared = DeepLinkRouter()
@Published var activeDeepLink: DeepLink?
func navigate(to link: DeepLink) {
activeDeepLink = link
}
func clear() {
activeDeepLink = nil
}
}
// In your root view:
struct ContentView: View {
@StateObject private var router = DeepLinkRouter.shared
var body: some View {
TabView {
HomeView()
.tabItem { Label("Home", systemImage: "house") }
SettingsView()
.tabItem { Label("Settings", systemImage: "gear") }
}
.onChange(of: router.activeDeepLink) { _, link in
guard let link else { return }
switch link {
case .chat(let id):
// Navigate to chat — e.g., set selected tab + push chat view
selectedTab = .home
navigationPath.append(ChatRoute(id: id))
case .settings:
selectedTab = .settings
default:
break
}
router.clear()
}
}
}Step 5: Notification Categories and Actionable Notifications
Notification categories let you define custom action buttons that appear when the user long-presses or swipes on a notification. This is extremely powerful — users can reply to messages, mark tasks complete, or snooze reminders without opening your app.
// NotificationManager+Categories.swift
import UserNotifications
extension NotificationManager {
/// Register all notification categories on app launch.
func registerCategories() {
// Messaging category with reply action
let replyAction = UNTextInputNotificationAction(
identifier: "REPLY",
title: "Reply",
options: [],
textInputButtonTitle: "Send",
textInputPlaceholder: "Type a reply..."
)
let messagingCategory = UNNotificationCategory(
identifier: "MESSAGING",
actions: [replyAction],
intentIdentifiers: [],
options: [.customDismissAction]
)
// Task category with complete and snooze actions
let completeAction = UNNotificationAction(
identifier: "MARK_COMPLETE",
title: "Mark Complete",
options: [.authenticationRequired]
)
let snoozeAction = UNNotificationAction(
identifier: "SNOOZE_1HR",
title: "Snooze 1 Hour",
options: []
)
let taskCategory = UNNotificationCategory(
identifier: "TASK_REMINDER",
actions: [completeAction, snoozeAction],
intentIdentifiers: [],
options: []
)
// Promotional category with view and dismiss
let viewAction = UNNotificationAction(
identifier: "VIEW_OFFER",
title: "View Offer",
options: [.foreground] // Opens the app
)
let promoCategory = UNNotificationCategory(
identifier: "PROMOTION",
actions: [viewAction],
intentIdentifiers: [],
options: []
)
UNUserNotificationCenter.current().setNotificationCategories([
messagingCategory,
taskCategory,
promoCategory,
])
}
}Call registerCategories() in your AppDelegate's application(_:didFinishLaunchingWithOptions:)— categories must be registered before any notification using them is delivered. To attach a category to a remote notification, include the category key in your APNs payload:
{
"aps": {
"alert": {
"title": "New message from Sarah",
"body": "Hey, are we still on for tomorrow?"
},
"category": "MESSAGING",
"mutable-content": 1
},
"chatId": "abc123",
"screen": "chat"
}Step 6: Rich Notifications with Notification Service Extension
Rich notifications let you display images, GIFs, audio, or video alongside the notification alert. They can also modify the notification content before it is displayed — useful for decrypting end-to-end encrypted messages or downloading attached media.
Rich notifications require a Notification Service Extension — a separate target in your Xcode project that runs in the background when a notification withmutable-content: 1 arrives.
6a. Creating the Extension
In Xcode, go to File → New → Target. Search for "Notification Service Extension" and add it. Xcode creates a new target with aNotificationService.swift file. Here is a production-ready implementation:
// NotificationServiceExtension/NotificationService.swift
import UserNotifications
class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent?
override func didReceive(
_ request: UNNotificationRequest,
withContentHandler contentHandler:
@escaping (UNNotificationContent) -> Void
) {
self.contentHandler = contentHandler
bestAttemptContent = request.content
.mutableCopy() as? UNMutableNotificationContent
guard let bestAttemptContent else {
contentHandler(request.content)
return
}
// Download and attach image if URL is provided
if let imageURLString = request.content
.userInfo["imageURL"] as? String,
let imageURL = URL(string: imageURLString) {
downloadAttachment(from: imageURL) { attachment in
if let attachment {
bestAttemptContent.attachments = [attachment]
}
contentHandler(bestAttemptContent)
}
} else {
contentHandler(bestAttemptContent)
}
}
override func serviceExtensionTimeWillExpire() {
// Called just before the extension is terminated by the system.
// Deliver the best attempt at modified content.
if let contentHandler, let bestAttemptContent {
contentHandler(bestAttemptContent)
}
}
private func downloadAttachment(
from url: URL,
completion: @escaping (UNNotificationAttachment?) -> Void
) {
let task = URLSession.shared.downloadTask(with: url) {
localURL, response, error in
guard let localURL, error == nil else {
completion(nil)
return
}
// Determine file extension from MIME type
let ext: String
if let mimeType = (response as? HTTPURLResponse)?
.mimeType {
switch mimeType {
case "image/jpeg": ext = "jpg"
case "image/png": ext = "png"
case "image/gif": ext = "gif"
default: ext = "jpg"
}
} else {
ext = "jpg"
}
// Move to a location with proper extension
let tempDir = FileManager.default.temporaryDirectory
let tempFile = tempDir
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension(ext)
do {
try FileManager.default.moveItem(
at: localURL,
to: tempFile
)
let attachment = try UNNotificationAttachment(
identifier: UUID().uuidString,
url: tempFile,
options: nil
)
completion(attachment)
} catch {
completion(nil)
}
}
task.resume()
}
}Memory limit: Notification Service Extensions have a strict 30-second execution limit and a ~24 MB memory ceiling. If your extension exceeds either,
serviceExtensionTimeWillExpire()is called and you must deliver whatever content you have. Keep your downloads small — compress images to under 1 MB on the server side before including the URL in the payload.
6b. APNs Payload for Rich Notifications
Your server must include mutable-content: 1 in the APNs payload for the service extension to trigger. Here is a complete payload example:
{
"aps": {
"alert": {
"title": "New photo from Alex",
"body": "Shared a sunset photo with you"
},
"mutable-content": 1,
"sound": "default",
"category": "MESSAGING"
},
"imageURL": "https://yourcdn.com/photos/sunset-thumb.jpg",
"chatId": "xyz789",
"screen": "chat"
}Step 7: Provisional Authorization (Quiet Notifications)
Provisional authorization, introduced in iOS 12, lets your app deliver notificationswithout showing the permission dialog. Notifications arrive quietly in Notification Center (no banner, no sound, no badge) with a "Keep" or "Turn Off" prompt. If the user taps "Keep," they can upgrade to prominent delivery.
This is ideal for apps where notifications add value but are not critical — news feeds, content recommendations, or weekly summaries. The user experiences the notifications before deciding whether to keep them.
// Provisional authorization — no prompt shown to the user
func requestProvisionalAuthorization() async {
do {
let granted = try await UNUserNotificationCenter
.current()
.requestAuthorization(options: [
.alert,
.sound,
.badge,
.provisional // This is the key flag
])
// 'granted' is always true for provisional
// Notifications will be delivered quietly
if granted {
await MainActor.run {
UIApplication.shared.registerForRemoteNotifications()
}
}
} catch {
print("Provisional auth failed: \(error.localizedDescription)")
}
}The user flow for provisional authorization is:
- Your app requests provisional authorization — no dialog is shown.
- Notifications arrive silently in Notification Center.
- Each notification has a "Keep..." and "Turn Off..." button below it.
- If the user taps "Keep," they choose between "Deliver Prominently" (banners + sounds) or "Deliver Quietly" (Notification Center only).
- If they tap "Turn Off," notifications are disabled for your app.
Strategy: Use provisional authorization on first install, then ask for full authorization after the user has received 2-3 valuable notifications. This gives them proof that your notifications are worth keeping before you ask for the commitment.
Step 8: Notification Grouping
Notification grouping prevents your app from flooding the lock screen with individual notifications. Instead of 15 separate message alerts, the user sees a single grouped stack they can expand. iOS groups notifications by the threadIdentifierproperty on the notification content.
// Group notifications by conversation
let content = UNMutableNotificationContent()
content.title = "Sarah"
content.body = "Are we still on for lunch?"
content.threadIdentifier = "chat-sarah-123" // Group key
content.summaryArgument = "Sarah" // Used in group summary
// For local notifications:
let request = UNNotificationRequest(
identifier: UUID().uuidString,
content: content,
trigger: nil // Deliver immediately
)
try await UNUserNotificationCenter.current().add(request)
// For remote notifications, include in the APNs payload:
// {
// "aps": {
// "alert": { "title": "Sarah", "body": "Are we still on?" },
// "thread-id": "chat-sarah-123",
// "summary-arg": "Sarah"
// }
// }You can also customize the group summary text by setting asummaryArgumentCount and defining a categorySummaryFormaton your notification category:
let messagingCategory = UNNotificationCategory(
identifier: "MESSAGING",
actions: [replyAction],
intentIdentifiers: [],
hiddenPreviewsBodyPlaceholder: "Message",
categorySummaryFormat: "%u more messages from %@",
options: []
)
// Result: "3 more messages from Sarah"Step 9: Silent Notifications and Background Fetch
Silent notifications wake your app in the background without showing any UI to the user. They are used for triggering background data syncs, preloading content, or updating the app state. The APNs payload omits the alert key and includescontent-available: 1.
// APNs payload for silent notification:
// {
// "aps": {
// "content-available": 1
// },
// "action": "sync-feed",
// "lastSyncTimestamp": "2026-03-30T10:00:00Z"
// }
// Handle in AppDelegate:
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler:
@escaping (UIBackgroundFetchResult) -> Void
) {
guard let action = userInfo["action"] as? String else {
completionHandler(.noData)
return
}
switch action {
case "sync-feed":
Task {
do {
let newItems = try await FeedService.shared.syncLatest()
completionHandler(
newItems > 0 ? .newData : .noData
)
} catch {
completionHandler(.failed)
}
}
case "refresh-cache":
Task {
try? await CacheService.shared.refreshAll()
completionHandler(.newData)
}
default:
completionHandler(.noData)
}
}Throttling warning: Apple throttles silent notifications aggressively. If you send too many, iOS will start dropping them. Apple does not publish exact rate limits, but in my experience, more than 2-3 silent notifications per hour will trigger throttling. Do not rely on silent notifications for time-critical operations. Use them for best-effort background syncing only.
Step 10: Sending Notifications from Your Server
To send remote notifications, your backend needs to make an HTTP/2 request to APNs. Here is a minimal example using a Supabase Edge Function with the .p8 key authentication method:
// supabase/functions/send-notification/index.ts
import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
import * as jose from "https://deno.land/x/jose@v4.14.4/index.ts";
const TEAM_ID = Deno.env.get("APPLE_TEAM_ID")!;
const KEY_ID = Deno.env.get("APPLE_KEY_ID")!;
const PRIVATE_KEY = Deno.env.get("APPLE_P8_KEY")!;
const BUNDLE_ID = Deno.env.get("APP_BUNDLE_ID")!;
async function generateAPNsToken(): Promise<string> {
const privateKey = await jose.importPKCS8(PRIVATE_KEY, "ES256");
const jwt = await new jose.SignJWT({})
.setProtectedHeader({ alg: "ES256", kid: KEY_ID })
.setIssuer(TEAM_ID)
.setIssuedAt()
.sign(privateKey);
return jwt;
}
serve(async (req) => {
const { deviceToken, title, body, data } = await req.json();
const token = await generateAPNsToken();
const payload = {
aps: {
alert: { title, body },
sound: "default",
"mutable-content": 1,
},
...data,
};
const response = await fetch(
`https://api.push.apple.com/3/device/${deviceToken}`,
{
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"apns-topic": BUNDLE_ID,
"apns-push-type": "alert",
"apns-priority": "10",
"apns-expiration": "0",
},
body: JSON.stringify(payload),
}
);
if (!response.ok) {
const error = await response.json();
return new Response(JSON.stringify({ error }), { status: 500 });
}
return new Response(JSON.stringify({ success: true }), { status: 200 });
});Handling Foreground Notifications in SwiftUI
By default, iOS suppresses notifications when your app is in the foreground. ThewillPresent delegate method from Step 4 controls this behavior. But sometimes you want more control — for example, showing an in-app banner instead of a system notification. Here is a SwiftUI-native approach:
// InAppNotificationBanner.swift
import SwiftUI
struct InAppNotification: Identifiable, Equatable {
let id = UUID()
let title: String
let body: String
let icon: String
static func == (lhs: Self, rhs: Self) -> Bool {
lhs.id == rhs.id
}
}
struct InAppNotificationBanner: View {
let notification: InAppNotification
let onDismiss: () -> Void
var body: some View {
HStack(spacing: 12) {
Image(systemName: notification.icon)
.font(.title2)
.foregroundStyle(.white)
VStack(alignment: .leading, spacing: 2) {
Text(notification.title)
.font(.subheadline.bold())
.foregroundStyle(.white)
Text(notification.body)
.font(.caption)
.foregroundStyle(.white.opacity(0.8))
.lineLimit(2)
}
Spacer()
}
.padding()
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
.transition(.move(edge: .top).combined(with: .opacity))
.onTapGesture(perform: onDismiss)
.task {
try? await Task.sleep(for: .seconds(4))
onDismiss()
}
}
}Production Best Practices
After shipping notifications across five production apps, here are the patterns that consistently improve engagement without annoying users:
Frequency Limits
- Transactional notifications (order updates, messages, security alerts): Send immediately, no limit. The user expects these.
- Engagement notifications (streaks, reminders, content suggestions): Maximum 1 per day. More than that and uninstall rates spike.
- Promotional notifications (sales, features, announcements): Maximum 2 per week. Anything more aggressive and you will see a surge in "Turn Off Notifications" actions.
- Re-engagement notifications (we miss you, come back): Maximum 1 per week, and stop after 3 attempts. If the user has not come back after 3 nudges, more notifications will not help.
Personalization Matters
- Use the user's name in the notification if available.
- Reference specific content — "Sarah sent you a message" is 3x more effective than "You have a new message."
- Respect time zones. A notification at 3 AM is an uninstall trigger.
- Let users choose notification categories in your app's settings. Some users want message alerts but not promotional notifications.
Technical Reliability
- Always re-register the device token on app launch. Tokens can change after OS updates, device restores, or app reinstalls. If your backend has a stale token, APNs returns a 410 Gone response and you should remove it from your database.
- Handle badge counts server-side. The badge number in the notification payload should reflect the actual unread count from your server, not an incremented local value. When the user opens the app, reset the badge to zero with
UIApplication.shared.applicationIconBadgeNumber = 0. - Test with APNs production endpoint before submitting. The sandbox (
api.sandbox.push.apple.com) and production (api.push.apple.com) environments use different device tokens. A token from a development build will not work on the production endpoint and vice versa.
Debugging Common Issues
These are the problems I hit most frequently, along with their solutions:
- Notifications not arriving: Check that (1) the user granted permission, (2) the device token is correct and not from a different environment (sandbox vs production), (3) your APNs authentication is valid, and (4) the
apns-topicmatches your bundle ID exactly. - Service extension not running: Confirm that
mutable-content: 1is in theapsdictionary (not at the top level), the extension target is included in the same app group, and the notification includes a visible alert (silent notifications do not trigger service extensions). - Actions not appearing: Ensure you called
setNotificationCategoriesbefore the notification arrived, and thecategoryin the payload matches the identifier you registered exactly (case-sensitive). - Deep linking not working: Verify that
UNUserNotificationCenter.current().delegateis set indidFinishLaunchingWithOptions(not later), and thatdidReceive responseis extracting the correct keys fromuserInfo. - Duplicate notifications: Each notification must have a unique
identifier. If you reuse the same identifier, the new notification replaces the old one (which is actually useful for updating existing notifications). - Badge not clearing: Call
UIApplication.shared.applicationIconBadgeNumber = 0inapplicationDidBecomeActiveor in your SwiftUI view's.onAppearmodifier via thescenePhaseenvironment value.
Testing Notifications During Development
Xcode supports sending test push notifications directly to the Simulator or a connected device. Create a .apns file with your test payload:
{
"Simulator Target Bundle": "com.yourcompany.yourapp",
"aps": {
"alert": {
"title": "Test Notification",
"body": "This is a test push notification from Xcode."
},
"sound": "default",
"badge": 3,
"category": "TASK_REMINDER",
"mutable-content": 1
},
"screen": "chat",
"chatId": "test-123",
"imageURL": "https://picsum.photos/600/400"
}Drag this file onto the Simulator window, or use the Xcode menu: Debug → Simulate Notification. This lets you test the full notification flow — including service extensions, categories, and deep linking — without needing a server.
Complete Integration Example
Here is how all the pieces come together in a real app. This is the setup I use in every new project:
// App.swift — Complete notification setup
import SwiftUI
@main
struct MyApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@StateObject private var router = DeepLinkRouter.shared
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(router)
.onChange(of: scenePhase) { _, phase in
if phase == .active {
// Clear badge when app becomes active
UIApplication.shared
.applicationIconBadgeNumber = 0
// Re-check notification authorization
Task {
await NotificationManager.shared
.checkCurrentStatus()
}
}
}
}
}
}
// AppDelegate.swift — Wiring everything together
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions:
[UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// 1. Set notification delegate FIRST
UNUserNotificationCenter.current().delegate = self
// 2. Register notification categories
NotificationManager.shared.registerCategories()
// 3. Check if launched from notification
if let remoteNotification = launchOptions?[
.remoteNotification
] as? [AnyHashable: Any] {
handleNotificationTap(userInfo: remoteNotification)
}
return true
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken
deviceToken: Data
) {
let token = deviceToken.map {
String(format: "%02.2hhx", $0)
}.joined()
Task { await sendTokenToServer(token) }
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError
error: Error
) {
print("APNs registration failed: \(error)")
}
private func sendTokenToServer(_ token: String) async {
// Save to your backend (Supabase, Firebase, etc.)
}
}Notification Privacy and App Store Review
A few things to keep in mind for App Store compliance:
- Privacy Nutrition Labels: If you collect device tokens (you do), you must declare "Device ID" under Data Used to Track You or Data Linked to You in App Store Connect, depending on how you use it. If the token is only used for notifications and not cross-app tracking, it falls under "Data Not Linked to You."
- EU Digital Markets Act: As of iOS 17.4 in the EU, users have more granular notification controls. Your app should gracefully handle partial permission states.
- Do not use notifications for advertising unless the user has explicitly opted in. Apple's App Store Review Guideline 4.5.3 can result in rejection if push notifications are used purely for marketing without user consent.
- Critical alerts require an entitlement from Apple. You cannot just request
.criticalAlert— you need to apply via the Apple Developer Portal and justify the use case (medical, safety, or security).
Skip the Boilerplate — Use The Swift Kit
Every piece of notification infrastructure in this tutorial — the manager singleton, permission flow, category registration, deep link router, APNs token handling, foreground presentation logic, and the service extension — is already built and wired into The Swift Kit. The notification module is integrated with the onboarding flow (so the permission ask happens at the optimal time), the Supabase backend (for device token storage), and the MVVM architecture (so notification state flows cleanly through your views).
You add your APNs key to the configuration, drop in your notification categories, and everything works. No debugging delegate timing issues. No figuring out why the service extension is not triggering. Check out the full feature list or visit pricing to get started. Spend your time crafting the perfect notification copy, not plumbing infrastructure.