Capacitor gives you one web codebase that ships as a real native app on iOS and Android. Push notifications are where that convenience usually breaks down: you end up in Xcode wiring APNs token callbacks, in Firebase generating service accounts, and in a Supabase Edge Function stitching it all together per platform.
This tutorial skips all of that. We build three real push notifications for a group chat app, straight from your Supabase schema, configured in the Entrig dashboard with no Edge Functions or custom backend code. The web layer stays plain TypeScript, the native side is a single SDK, and on iOS we use Swift Package Manager with no CocoaPods.
Each of the three notifications is a different recipient pattern: one-to-one, one-to-many through a join table, and broadcast.
The schema
A minimal group chat app with four tables:
| Table | Columns |
|---|---|
users | id, name, created_at |
groups | id, name, created_by → users.id, created_at |
group_members | group_id → groups.id, user_id → users.id, created_at |
messages | id, content, group_id → groups.id, user_id → users.id, created_at |
The three notifications we’re building:
- Member joins a group → notify the group owner (one-to-one)
- New message in a group → notify all members except the sender (one-to-many)
- New group created → notify all users except the creator (broadcast)
Set up the SDK
Install the plugin. @capacitor/app is used later for deeplink handling, so add it now too:
npm install @entrig/capacitor @capacitor/app
npx cap sync
npx cap sync copies the web assets and installs the native plugin on both platforms. On iOS, Capacitor 8 uses Swift Package Manager, so the Entrig plugin is added to ios/App/CapApp-SPM/Package.swift for you. There is no Podfile and no pod install step.
iOS setup
Run the setup helper from your project root. It configures the pieces push notifications need on iOS: the APNs token handlers in AppDelegate.swift, the push entitlement in App.entitlements, and remote-notification background mode in Info.plist:
npx @entrig/capacitor setup ios
The command writes .backup files before it edits anything, so you can revert if needed and delete them once the build works.
Open the iOS app in Xcode and confirm the Push Notifications capability is present under Signing & Capabilities. Push delivery requires a paid Apple Developer account and a signed build on a real device:
npx cap open ios
Android setup
Nothing to do. npx cap sync wires the native Entrig SDK into your Android project, and it configures FCM message handling for you.
Initialize and register
Initialize Entrig once at app startup. Set autoOpenDeeplink: true so a tapped notification opens its URL for you, which we handle in the next section:
import { Entrig } from '@entrig/capacitor';
await Entrig.init({
apiKey: 'YOUR_ENTRIG_API_KEY',
autoOpenDeeplink: true,
});
Register the device against the Supabase Auth user ID. The userId you pass to Entrig.register() must be the Supabase Auth user ID, because that is the ID Entrig uses to resolve which device to send to. Drive it off Supabase auth state so the device registers on sign-in and unregisters on sign-out:
import { Entrig } from '@entrig/capacitor';
import { createClient } from '@supabase/supabase-js';
const supabase = createClient('YOUR_SUPABASE_URL', 'YOUR_SUPABASE_ANON_KEY');
// Register an existing session on launch
const { data: { session } } = await supabase.auth.getSession();
if (session?.user) {
await Entrig.register({ userId: session.user.id });
}
// Follow auth state changes after that
supabase.auth.onAuthStateChange(async (event, session) => {
if (event === 'SIGNED_IN' && session?.user) {
await Entrig.register({ userId: session.user.id });
} else if (event === 'SIGNED_OUT') {
await Entrig.unregister();
}
});
With handlePermission at its default of true, the SDK requests notification permission for you during register(). See the Capacitor 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, so the same configuration serves your iOS and Android builds. 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_id≠groups.created_by(row value)Skip the notification when the joiner is the owner. Apps typically add the creator to
group_membersat 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.idEntrig reads your schema and auto-detects this path. Select it from the list. It follows
group_idto thegroupstable, readscreated_by, and resolves that to theuserstable. -
Recipient filters: none
Message
Add payload fields so the title and body can reference them:
groups.name: name of the groupusers.name: name of the user who joined
Set the title and body:
| Field | Value |
|---|---|
| Title | New 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_id→group_members(match ongroup_id) →user_id→users.idEntrig takes the
group_idfrom the new message row, finds all matching rows ingroup_members, and from each of those resolvesuser_idto a registered device. -
Recipient filter:
group_members.user_id≠messages.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 anygroup_membersrow where theuser_idmatches the sender.
Message
Add payload fields:
groups.name: group name for the notification titleusers.name: sender’s namemessages.content: the message text
Set the title and body:
| Field | Value |
|---|---|
| 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.id≠groups.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 nameusers.name: the creator’s name
Set the title and body:
| Field | Value |
|---|---|
| Title | New 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 initialized the SDK with autoOpenDeeplink: true, tapping a notification opens that URL for you. The plugin routes it through the OS, and Capacitor surfaces it to your web layer as an appUrlOpen event.
First register the groupchat:// scheme on each platform so the OS delivers the URL to your app.
iOS in ios/App/App/Info.plist:
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>groupchat</string>
</array>
</dict>
</array>
Android in android/app/src/main/AndroidManifest.xml, inside the main <activity>:
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="groupchat" />
</intent-filter>
Then listen for the incoming URL with @capacitor/app and navigate. The host carries the group ID and the name query param carries the group name, so you can open the chat screen straight from the URL:
import { App } from '@capacitor/app';
App.addListener('appUrlOpen', ({ url }) => {
// url looks like: groupchat://<group-id>?name=<group-name>
const parsed = new URL(url);
if (parsed.protocol !== 'groupchat:') return;
const groupId = parsed.host;
const groupName = parsed.searchParams.get('name') ?? 'Group';
// route to your chat screen
navigate(`/chat/${groupId}`, { name: groupName });
});
If the app was fully terminated when the notification was tapped, the OS may deliver the tap before your appUrlOpen listener is attached. Read the launch notification once at startup and open its deeplink, which routes back through the same handler:
const initial = await Entrig.getInitialNotification();
if (initial?.deeplink) {
const parsed = new URL(initial.deeplink);
navigate(`/chat/${parsed.host}`, {
name: parsed.searchParams.get('name') ?? 'Group',
});
}
Prefer to handle the tap yourself? Leave
autoOpenDeeplinkat its default offalseand read the deeplink from the tap listener instead:Entrig.addListener('onNotificationOpened', (event) => { if (!event.deeplink) return; const parsed = new URL(event.deeplink); navigate(`/chat/${parsed.host}`); });Pick one approach. With
autoOpenDeeplink: truethe URL already opens throughappUrlOpen, so do not also navigate fromonNotificationOpenedor 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), add a foreground listener:
Entrig.addListener('onForegroundNotification', (event) => {
// event.title, event.body, event.type, event.data
});
Test it
Push notifications require a real device. The iOS simulator does not receive APNs pushes, and an Android emulator needs Google Play services to receive FCM. 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 Capacitor 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.