Skip to main content
Notification extensions allow you to customize how push notifications appear and behave. There are two types:
  • Notification Service Extension — Modifies notification content before display (e.g., download images, decrypt content)
  • Notification Content Extension — Provides custom UI for notifications

Notification Service Extension

Service extensions intercept remote notifications before they’re displayed, allowing you to modify the content or download attachments.

Getting Started

1

Generate the service extension

npx create-target notification-service
This creates targets/notification-service/ with:
  • expo-target.config.js — Target configuration
  • NotificationService.swift — Extension implementation
2

Configure the target

targets/notification-service/expo-target.config.js
/** @type {import('@bacons/apple-targets/app.plugin').Config} */
module.exports = {
  type: "notification-service",
  deploymentTarget: "15.0",
};
3

Run prebuild

npx expo prebuild -p ios --clean

Implementation

The generated service extension provides a starting point:
targets/notification-service/NotificationService.swift
import UserNotifications

class NotificationService: UNNotificationServiceExtension {

    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?

    override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
        
        if let bestAttemptContent = bestAttemptContent {
            // Modify the notification content here...
            bestAttemptContent.title = "\(bestAttemptContent.title) [modified]"
            
            contentHandler(bestAttemptContent)
        }
    }
    
    override func serviceExtensionTimeWillExpire() {
        // Called just before the extension will be terminated by the system.
        // Use this as an opportunity to deliver your "best attempt" at modified content,
        // otherwise the original push payload will be used.
        if let contentHandler = contentHandler, let bestAttemptContent =  bestAttemptContent {
            contentHandler(bestAttemptContent)
        }
    }

}

Downloading Images

Here’s how to download and attach images to notifications:
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
    self.contentHandler = contentHandler
    bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
    
    guard let bestAttemptContent = bestAttemptContent,
          let imageURLString = request.content.userInfo["image_url"] as? String,
          let imageURL = URL(string: imageURLString) else {
        contentHandler(request.content)
        return
    }
    
    // Download the image
    downloadImage(from: imageURL) { attachment in
        if let attachment = attachment {
            bestAttemptContent.attachments = [attachment]
        }
        contentHandler(bestAttemptContent)
    }
}

func downloadImage(from url: URL, completion: @escaping (UNNotificationAttachment?) -> Void) {
    let task = URLSession.shared.downloadTask(with: url) { localURL, response, error in
        guard let localURL = localURL else {
            completion(nil)
            return
        }
        
        // Move to a location that won't be deleted
        let tempDirectory = FileManager.default.temporaryDirectory
        let targetURL = tempDirectory.appendingPathComponent(url.lastPathComponent)
        
        try? FileManager.default.removeItem(at: targetURL)
        try? FileManager.default.moveItem(at: localURL, to: targetURL)
        
        let attachment = try? UNNotificationAttachment(identifier: "", url: targetURL, options: nil)
        completion(attachment)
    }
    task.resume()
}

Decrypting Content

For end-to-end encrypted notifications:
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
    self.contentHandler = contentHandler
    bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
    
    guard let bestAttemptContent = bestAttemptContent,
          let encryptedBody = request.content.userInfo["encrypted_body"] as? String else {
        contentHandler(request.content)
        return
    }
    
    // Decrypt the content
    if let decryptedBody = decrypt(encryptedBody) {
        bestAttemptContent.body = decryptedBody
    }
    
    contentHandler(bestAttemptContent)
}

func decrypt(_ encrypted: String) -> String? {
    // Implement your decryption logic
    return encrypted // placeholder
}

Sending the Notification Payload

For the service extension to run, include "mutable-content": 1 in your push notification:
{
  "aps": {
    "alert": {
      "title": "New Message",
      "body": "You have a new message"
    },
    "mutable-content": 1
  },
  "image_url": "https://example.com/image.jpg"
}

Notification Content Extension

Content extensions provide custom UI for notifications when users long-press or 3D Touch them.

Getting Started

1

Generate the content extension

npx create-target notification-content
This creates targets/notification-content/ with:
  • expo-target.config.js — Target configuration
  • NotificationViewController.swift — View controller
  • MainInterface.storyboard — Interface builder file (optional)
2

Configure the target

targets/notification-content/expo-target.config.js
module.exports = {
  type: "notification-content",
  deploymentTarget: "15.0",
};
3

Customize Info.plist

Edit targets/notification-content/Info.plist to specify which notifications use your custom UI:
<key>NSExtension</key>
<dict>
    <key>NSExtensionAttributes</key>
    <dict>
        <key>UNNotificationExtensionCategory</key>
        <string>CUSTOM_NOTIFICATION</string>
        <key>UNNotificationExtensionDefaultContentHidden</key>
        <false/>
        <key>UNNotificationExtensionInitialContentSizeRatio</key>
        <real>1.0</real>
    </dict>
</dict>

Implementation

targets/notification-content/NotificationViewController.swift
import UIKit
import UserNotifications
import UserNotificationsUI

class NotificationViewController: UIViewController, UNNotificationContentExtension {

    @IBOutlet var label: UILabel?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any required interface initialization here.
    }
    
    func didReceive(_ notification: UNNotification) {
        self.label?.text = notification.request.content.body
    }

}

Custom SwiftUI View

For a more modern approach, use SwiftUI:
import UIKit
import UserNotifications
import UserNotificationsUI
import SwiftUI

class NotificationViewController: UIViewController, UNNotificationContentExtension {
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    func didReceive(_ notification: UNNotification) {
        let content = notification.request.content
        let contentView = NotificationContentView(
            title: content.title,
            body: content.body,
            imageURL: content.userInfo["image_url"] as? String
        )
        
        let hostingController = UIHostingController(rootView: contentView)
        addChild(hostingController)
        hostingController.view.frame = view.bounds
        view.addSubview(hostingController.view)
        hostingController.didMove(toParent: self)
    }
}

struct NotificationContentView: View {
    let title: String
    let body: String
    let imageURL: String?
    
    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            Text(title)
                .font(.headline)
            
            Text(body)
                .font(.body)
            
            if let imageURL = imageURL, let url = URL(string: imageURL) {
                AsyncImage(url: url) { image in
                    image
                        .resizable()
                        .aspectRatio(contentMode: .fill)
                } placeholder: {
                    ProgressView()
                }
                .frame(height: 200)
                .cornerRadius(12)
            }
        }
        .padding()
    }
}

Sending the Notification Payload

Include the category in your push notification:
{
  "aps": {
    "alert": {
      "title": "New Photo",
      "body": "Someone shared a photo with you"
    },
    "category": "CUSTOM_NOTIFICATION",
    "mutable-content": 1
  },
  "image_url": "https://example.com/photo.jpg"
}

Interactive Notifications

Add action buttons to notifications:

Register Categories in Your App

import * as Notifications from 'expo-notifications';

// Register notification categories
await Notifications.setNotificationCategoryAsync('MEETING_INVITE', [
  {
    identifier: 'ACCEPT',
    buttonTitle: 'Accept',
    options: {
      opensAppToForeground: true,
    },
  },
  {
    identifier: 'DECLINE',
    buttonTitle: 'Decline',
    options: {
      opensAppToForeground: false,
    },
  },
]);

Handle Actions in Content Extension

func didReceive(_ response: UNNotificationResponse, completionHandler completion: @escaping (UNNotificationContentExtensionResponseOption) -> Void) {
    if response.actionIdentifier == "ACCEPT" {
        // Handle accept action
        completion(.dismiss)
    } else if response.actionIdentifier == "DECLINE" {
        // Handle decline action
        completion(.dismiss)
    }
}

Testing Notifications

Using Xcode

  1. Run your app on a device or simulator
  2. Background the app
  3. Send a test notification:
xcrun simctl push booted com.yourapp.bundleidentifier notification.json
Where notification.json contains:
{
  "aps": {
    "alert": {
      "title": "Test Notification",
      "body": "This is a test"
    },
    "category": "CUSTOM_NOTIFICATION",
    "mutable-content": 1
  }
}

Using Expo Push Notifications

import * as Notifications from 'expo-notifications';

await Notifications.scheduleNotificationAsync({
  content: {
    title: 'Test Notification',
    body: 'This is a test',
    categoryIdentifier: 'CUSTOM_NOTIFICATION',
    data: {
      image_url: 'https://example.com/image.jpg',
    },
  },
  trigger: { seconds: 2 },
});

Debugging

View logs: Attach to the notification extension process in Xcode (Debug > Attach to Process by PID or Name) to see console output.
Time limit: Service extensions have ~30 seconds to complete. After that, serviceExtensionTimeWillExpire() is called.
Test on device: Notification extensions work best on real devices. Simulators have limitations.

Next Steps

Build docs developers (and LLMs) love