Push Notifications

Configure notification permissions, the local inbox, Expo push tokens, and optional server sending.

The Expo template includes the client-side notification experience: permission priming, Android channels, Expo push token retrieval, foreground handling, a small in-app inbox, and tap-to-open deep links. It does not ship a complete push-sending backend.

Real remote push testing needs a native development, preview, or production build. Expo documents that expo-notifications remote push support is not available in Expo Go on Android from SDK 53 onward, and this template uses Expo SDK 54.

What it provides

SurfaceStatusNotes
NotificationsProviderIntegratedLazy-loads expo-notifications, survives web or builds where the module is missing, and exposes useNotifications()
app/permissions.tsxIntegratedPrimes notification, photo, camera, and tracking permissions from one screen
app/notifications.tsxIntegratedShows permission status, the current Expo push token, cached notification inbox entries, and a clear action
Android channelsIntegratedRegisters default and alerts channels before token requests
Notification tap routingIntegratedReads data.url from the notification response and forwards it through openDeepLink()
Server sendingNot wiredThe client does not persist tokens; the optional send-test-push function expects a missing device_tokens table

lib/notifications.tsx wraps the native module in a lazy require. If expo-notifications cannot load, isNativePushAvailable is false and the request action returns a friendly error instead of crashing the app.

Client flow

Register handlers

On mount, NotificationsProvider installs a foreground handler that shows the alert, does not play a sound, and does not set a badge. It also subscribes to incoming notifications and notification responses.

Create Android channels

On Android, the provider creates:

default -> General, default importance
alerts  -> Alerts, high importance

Android 13+ requires notification permission, and the system prompt depends on channels existing before a push token request.

Request permission and token

requestPermission() calls requestPermissionsAsync(). When permission is granted, it calls getExpoPushTokenAsync() and stores the returned token in React state for the current session.

Keep a local inbox

Foreground notifications are normalized to { id, title, body, url, createdAt }, prepended to the inbox, capped at 50 entries, and persisted with lib/cache.ts under cache.notifications.inbox.v1.

Forward taps to routes

If a notification response includes data.url, the provider calls:

openDeepLink(url, "/notifications");

Only routes allowed by lib/deep-links.ts are opened; otherwise the fallback notifications screen is used.

What is not wired

The client retrieves an Expo push token, but it never writes that token to Supabase. The shipped Supabase function send-test-push is an optional APNs debug artifact copied from the native stack. It queries:

public.device_tokens

No migration creates that table, and the Expo client never registers native APNs tokens. Treat that function as a starting point, not as a working Expo push backend.

If you keep the APNs function, update its fallback deep link too: send-test-push currently falls back to soar:///notifications, while this template's default schemes are soar-starter-expo-dev://, soar-starter-expo-preview://, and soar-starter-expo://.

To actually send pushes

For the Expo push service path:

Add a token table

Create an owner-scoped table for Expo push tokens:

create table public.device_push_tokens (
  id uuid primary key default gen_random_uuid(),
  user_id uuid not null references auth.users(id) on delete cascade,
  platform text not null,
  expo_push_token text not null,
  created_at timestamptz not null default now(),
  updated_at timestamptz not null default now(),
  unique (user_id, expo_push_token)
);

alter table public.device_push_tokens enable row level security;

Add insert/select/delete policies scoped to auth.uid() = user_id.

Persist tokens after permission

After getExpoPushTokenAsync() succeeds, upsert the token for the signed-in user. Delete stale rows on sign-out if you do not want a shared device to keep receiving account-specific messages.

Send through Expo

Create a Supabase Edge Function or your own API route that reads device_push_tokens, calls Expo's push API, and removes invalid receipts. Keep server auth and any provider secrets outside the app bundle.

If you prefer direct APNs/FCM, extend the existing send-test-push artifact: add a real device_tokens migration, collect native device tokens instead of Expo push tokens, and add the Android FCM path before relying on it in a shipped app.

Verify

  • Install a native development build on a physical device.
  • Open Permissions and request Notifications.
  • Open Notifications and confirm the status changes to granted.
  • Confirm an Expo push token appears. If it remains blank, check native credentials and the runtime logs.
  • Send a test push with data.url set to an allowed route such as /notifications or /refer, then confirm tapping the notification opens that route.

On this page