iOS Push Notifications with Supabase and Entrig

Entrig
Ib Entrig Team

Most push notification tutorials cover the basics: one user, one device, one notification. Real apps are messier. A group chat needs to notify the right people at the right time: the group owner when someone joins, everyone in a room when a message arrives, all users when a new group is created.

Each of those is a different recipient pattern: one-to-one, one-to-many via a join table, and broadcast. This tutorial builds all three in native iOS with Swift, using a real Supabase schema, configured in the Entrig dashboard with no Edge Functions or custom backend code.


The schema

A minimal group chat app with four tables:

TableColumns
usersid, name, created_at
groupsid, name, created_by → users.id, created_at
group_membersgroup_id → groups.id, user_id → users.id, created_at
messagesid, content, group_id → groups.id, user_id → users.id, created_at

The three notifications we’re building:

  1. Member joins a group → notify the group owner (one-to-one)
  2. New message in a group → notify all members except the sender (one-to-many)
  3. New group created → notify all users except the creator (broadcast)

Set up the SDK

Add the package in Xcode with File → Add Package Dependencies, then enter the repository URL and add it to your app target:

https://github.com/entrig/entrig-ios.git

Push notifications need two capabilities. In Xcode, select your target → Signing & Capabilities:

  • + Capability → Push Notifications
  • + Capability → Background Modes, then enable Remote notifications

Wire the SDK into your AppDelegate. Entrig.configure sets up the SDK, checkLaunchNotification captures a notification that launched the app from a terminated state, and the two UNUserNotificationCenterDelegate methods hand foreground and tap events to Entrig:

import UIKit
import UserNotifications
import Entrig

@main
class AppDelegate: UIResponder, UIApplicationDelegate {

    func application(
        _ application: UIApplication,
        didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
    ) -> Bool {

        let config = EntrigConfig(
            apiKey: "YOUR_ENTRIG_API_KEY",
            autoOpenDeeplink: true
        )
        Entrig.configure(config: config)

        Entrig.checkLaunchNotification(launchOptions)
        UNUserNotificationCenter.current().delegate = self
        return true
    }

    // MARK: - APNs Callbacks

    func application(
        _ application: UIApplication,
        didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
    ) {
        Entrig.didRegisterForRemoteNotifications(deviceToken: deviceToken)
    }

    func application(
        _ application: UIApplication,
        didFailToRegisterForRemoteNotificationsWithError error: Error
    ) {
        Entrig.didFailToRegisterForRemoteNotifications(error: error)
    }
}

// MARK: - UNUserNotificationCenterDelegate

extension AppDelegate: UNUserNotificationCenterDelegate {

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        willPresent notification: UNNotification,
        withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
    ) {
        Entrig.willPresentNotification(notification)
        completionHandler(Entrig.getPresentationOptions())
    }

    func userNotificationCenter(
        _ center: UNUserNotificationCenter,
        didReceive response: UNNotificationResponse,
        withCompletionHandler completionHandler: @escaping () -> Void
    ) {
        Entrig.didReceiveNotificationResponse(response)
        completionHandler()
    }
}

Register the device against the Supabase Auth user ID right after sign-in. The userId you pass to Entrig.register() must be the Supabase Auth user ID. This is the ID Entrig uses to resolve which device to send to. Pass isDebug: true on debug builds so the device registers against the APNs sandbox, and false for release builds that use production APNs:

let userId = try await supabase.auth.session.user.id.uuidString

#if DEBUG
let isDebug = true
#else
let isDebug = false
#endif

Entrig.register(userId: userId, isDebug: isDebug) { success, error in
    if success {
        // Device registered for notifications
    }
}

With handlePermission at its default of true, the SDK requests notification permission for you during register(). When the user signs out, unregister the device so it stops receiving pushes:

Entrig.unregister { success, error in
    if success {
        // Device unregistered
    }
}

See the iOS SDK docs for the full setup including manual permission handling and foreground notification options.


Configure the notifications

Each notification is created in the Entrig dashboard in three steps: Trigger (when), Users (who), Message (what). None of this is platform specific, it runs on the Entrig backend against your Supabase schema. The three tabs below walk through each notification type.

When a user joins a group, the person who created that group should know. This is a one-to-one notification: one join event, one recipient, found by navigating through two foreign keys to reach the owner.

Trigger

  • Table: group_members

  • Event: INSERT

  • Condition: group_members.user_idgroups.created_by (row value)

    Skip the notification when the joiner is the owner. Apps typically add the creator to group_members at the moment the group is created, so without this guard the owner would get a “someone joined your group” push about themselves.

Users

  • Users table: users

  • Connection type: Direct

    Direct means the trigger event maps to a single user. Entrig follows foreign keys to find them.

  • Path: group_members.group_id → groups → created_by → users.id

    Entrig reads your schema and auto-detects this path. Select it from the list. It follows group_id to the groups table, reads created_by, and resolves that to the users table.

  • Recipient filters: none

Message

Add payload fields so the title and body can reference them:

  • groups.name: name of the group
  • users.name: name of the user who joined

Set the title and body:

FieldValue
TitleNew member in {{groups.name}}
Body{{users.name}} just joined your group

Set the deeplink so a tap lands on the group. Placeholders are rendered at send time, so the URL arrives with the real values:

groupchat://{{group_members.group_id}}?name={{groups.name}}

When a message is sent, every member of that group should receive a notification, except the sender. This uses an indirect connection through the group_members join table, with a recipient filter to exclude the sender using a row value comparison.

Trigger

  • Table: messages
  • Event: INSERT
  • Conditions: none

Users

  • Users table: users

  • Connection type: Indirect via Join Table

    Indirect means the trigger event maps to multiple users through a relationship table.

  • Path: messages.group_idgroup_members (match on group_id) → user_idusers.id

    Entrig takes the group_id from the new message row, finds all matching rows in group_members, and from each of those resolves user_id to a registered device.

  • Recipient filter: group_members.user_idmessages.user_id (row value)

    This is the exclude-sender step. Set the filter value source to row value and select messages.user_id. Entrig will skip any group_members row where the user_id matches the sender.

Message

Add payload fields:

  • groups.name: group name for the notification title
  • users.name: sender’s name
  • messages.content: the message text

Set the title and body:

FieldValue
Title{{groups.name}}
Body{{users.name}}: {{messages.content}}

Set the deeplink so a tap opens the group the message was sent in:

groupchat://{{messages.group_id}}?name={{groups.name}}

When a new group is created, every other user in the app should be notified so they can choose to join. This is a broadcast: the trigger table has no join path to users, so Entrig notifies everyone registered, filtered to exclude the creator.

Trigger

  • Table: groups
  • Event: INSERT
  • Conditions: none

Users

  • Users table: users

  • Connection type: Broadcast

    Broadcast notifies all users in the users table when the event fires. No join table needed.

  • Recipient filter: users.idgroups.created_by (row value)

    Set the filter value source to row value and select groups.created_by. This excludes the creator from the broadcast. They don’t need to be told about a group they just made.

Message

Add payload fields:

  • groups.name: the new group’s name
  • users.name: the creator’s name

Set the title and body:

FieldValue
TitleNew group: {{groups.name}}
Body{{users.name}} created a new group. Tap to join

Set the deeplink so a tap opens the new group:

groupchat://{{groups.id}}?name={{groups.name}}

Handle notification taps

All three notifications carry a deeplink of the form groupchat://<group-id>?name=<group-name>. Because we configured the SDK with autoOpenDeeplink: true, tapping a notification opens that URL for you and iOS routes it to your app. No tap listener or manual parsing is required.

Register the URL scheme so iOS delivers groupchat:// URLs to your app. In Info.plist:

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>groupchat</string>
    </array>
  </dict>
</array>

Handle the opened URL in your AppDelegate. The host carries the group ID and the name query item carries the group name, so you can push the chat screen straight from the URL:

func application(
    _ app: UIApplication,
    open url: URL,
    options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
    guard url.scheme == "groupchat",
          let groupId = url.host, !groupId.isEmpty else {
        return false
    }

    let groupName = URLComponents(url: url, resolvingAgainstBaseURL: false)?
        .queryItems?.first(where: { $0.name == "name" })?.value ?? "Group"

    guard let nav = window?.rootViewController as? UINavigationController else {
        return false
    }

    nav.popToRootViewController(animated: false)
    nav.pushViewController(ChatViewController(groupId: groupId, groupName: groupName), animated: true)
    return true
}

If the app was fully terminated when the notification was tapped, iOS does not call application(_:open:options:). Read the launch notification once from your first screen and open its deeplink, which routes back through the same handler above:

private func handleColdStartDeeplink() {
    guard let notification = Entrig.getInitialNotification(),
          let deeplink = notification.deeplink,
          let url = URL(string: deeplink) else { return }
    UIApplication.shared.open(url)
}

getInitialNotification() returns nil after the first call, so it is safe to invoke on every launch.

Prefer to handle the tap yourself? Leave autoOpenDeeplink at its default of false and read the URL from a listener instead:

Entrig.setOnNotificationOpenedListener(self)

extension ChatCoordinator: OnNotificationClickListener {
    func onNotificationClick(_ notification: NotificationEvent) {
        guard let deeplink = notification.deeplink,
              let url = URL(string: deeplink) else { return }
        // parse groupchat://<id>?name=<name> and navigate
    }
}

Pick one approach. With autoOpenDeeplink: true the listener still fires, so do not also navigate from it or you will open the screen twice.

To react to a notification while the app is in the foreground (for example to refresh the message list in place instead of showing a banner), set a foreground listener:

Entrig.setOnForegroundNotificationListener(self)

extension ChatCoordinator: OnNotificationReceivedListener {
    func onNotificationReceived(_ notification: NotificationEvent) {
        // notification.title, notification.body, notification.type, notification.data
    }
}

Test it

Push notifications require a real device. The simulator does not receive APNs pushes. With your app running, trigger each notification by inserting a row from the Supabase dashboard or SQL editor:

-- Notification 1: member joins a group
INSERT INTO group_members (group_id, user_id)
VALUES ('your-group-id', 'joining-user-id');

-- Notification 2: new message
INSERT INTO messages (content, group_id, user_id)
VALUES ('Hey everyone!', 'your-group-id', 'sender-user-id');

-- Notification 3: new group created
INSERT INTO groups (name, created_by)
VALUES ('Weekend Plans', 'creator-user-id');

Each insert should deliver the notification to the right device within a few seconds.


The three notifications in this tutorial cover the full range of what Entrig supports: Direct for targeted one-to-one, Indirect for group-aware delivery, and Broadcast for app-wide announcements. The same patterns apply to any app: e-commerce orders, task assignments, social follows.

Full SDK reference at entrig.com/docs.


Read the Push Notifications for Supabase overview for a comparison of the manual setup versus Entrig.


Start free with Entrig and get this running on your own Supabase project in minutes. More at entrig.com.