Every Expo push notification tutorial ends the same way: you get a token on the device, paste it into Expo's push tool, a notification appears, done.
That's the first half. The second half is what runs in production: storing tokens per user, sending from your backend, checking receipts, and deleting dead tokens before Apple and Google start throttling you. Most apps skip it, and it's why "push works in testing but not for real users" is such a common bug report.
This is the backend half, using Supabase for storage and an Edge Function for sending. Two quick ground rules first:
-
Expo Go can't receive remote push since SDK 53. You need a development build (
eas build --profile development). - A token identifies a device, not a user. One user can have three devices; one device can be handed to a new user after sign-out. Model it that way.
Step 1: A token table that survives real life
create table public.push_tokens (
token text primary key,
user_id uuid not null references auth.users(id) on delete cascade,
platform text not null check (platform in ('ios', 'android')),
updated_at timestamptz not null default now()
);
alter table public.push_tokens enable row level security;
create policy "own tokens" on public.push_tokens
for all to authenticated
using ((select auth.uid()) = user_id)
with check ((select auth.uid()) = user_id);
The token is the primary key on purpose. When a device changes hands, an upsert on the token reassigns it to the new user instead of leaving a stale row that pushes the previous user's notifications to a stranger.
Step 2: Register the token on the device
import * as Notifications from 'expo-notifications';
import * as Device from 'expo-device';
import Constants from 'expo-constants';
import { Platform } from 'react-native';
import { supabase } from '@/lib/supabase';
export async function registerPushToken() {
if (!Device.isDevice) return; // simulators don't get tokens
const { status } = await Notifications.requestPermissionsAsync();
if (status !== 'granted') return;
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'Default',
importance: Notifications.AndroidImportance.DEFAULT,
});
}
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
const { data: token } = await Notifications.getExpoPushTokenAsync({ projectId });
await supabase.from('push_tokens').upsert({
token,
user_id: (await supabase.auth.getUser()).data.user!.id,
platform: Platform.OS,
updated_at: new Date().toISOString(),
});
}
Call it after sign-in and on every app launch. On sign-out, delete the row for this device's token so the next user doesn't inherit it.
Step 3: Send from an Edge Function
Never send from the client: the Expo push endpoint doesn't need a secret, but your token table does, and you don't want app code deciding who gets notified.
// supabase/functions/send-push/index.ts
import { createClient } from 'npm:@supabase/supabase-js@2';
import { Expo } from 'npm:expo-server-sdk';
const expo = new Expo();
Deno.serve(async (req) => {
const { userId, title, body, data } = await req.json();
const admin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! // server-only; never in the app
);
const { data: rows } = await admin
.from('push_tokens').select('token').eq('user_id', userId);
const messages = (rows ?? [])
.filter((r) => Expo.isExpoPushToken(r.token))
.map((r) => ({ to: r.token, title, body, data, sound: 'default' }));
const tickets = [];
for (const chunk of expo.chunkPushNotifications(messages)) {
tickets.push(...(await expo.sendPushNotificationsAsync(chunk)));
}
// Store ticket ids so a later job can fetch receipts
await admin.from('push_tickets').insert(
tickets
.filter((t) => t.status === 'ok')
.map((t, i) => ({ ticket_id: t.id, token: messages[i].to }))
);
return Response.json({ sent: tickets.length });
});
Call it from a database trigger, a webhook, or another function with the user's ID. The service-role key stays in the function's environment.
Step 4: Check receipts (the step everyone skips)
A "ticket" only means Expo accepted the message. Whether Apple or Google delivered it comes back later as a receipt. If you never read receipts, you never learn that a token is dead, and you keep sending to it. Apple and Google both throttle apps that do that.
Run this as a scheduled function 15–30 minutes after sends:
// supabase/functions/check-receipts/index.ts
const { data: pending } = await admin
.from('push_tickets').select('ticket_id, token').eq('checked', false).limit(1000);
for (const chunk of expo.chunkPushNotificationReceiptIds(pending.map((p) => p.ticket_id))) {
const receipts = await expo.getPushNotificationReceiptsAsync(chunk);
for (const [id, receipt] of Object.entries(receipts)) {
if (receipt.status === 'error' && receipt.details?.error === 'DeviceNotRegistered') {
const row = pending.find((p) => p.ticket_id === id);
await admin.from('push_tokens').delete().eq('token', row!.token);
}
}
await admin.from('push_tickets').update({ checked: true }).in('ticket_id', chunk);
}
DeviceNotRegistered means the user uninstalled, revoked permission, or the token expired. Delete it. That single loop is the difference between a push system that works for six months and one that quietly degrades.
Step 5: Handle taps
A notification that opens the app to the wrong screen feels broken. Read data and route:
import { useRouter } from 'expo-router';
import * as Notifications from 'expo-notifications';
const router = useRouter();
useEffect(() => {
const sub = Notifications.addNotificationResponseReceivedListener((res) => {
const { screen, id } = res.notification.request.content.data as any;
if (screen === 'order') router.push(`/orders/${id}`);
});
return () => sub.remove();
}, []);
Also check Notifications.getLastNotificationResponseAsync() on cold start, since the listener isn't mounted yet when a tap launches the app.
Checklist before you ship
- [ ] Tokens stored per device with user reassignment on upsert
- [ ] Token deleted on sign-out
- [ ] Sends happen server-side with the service-role key in the function's env only
- [ ] Tickets stored; receipts checked on a schedule;
DeviceNotRegisteredtokens deleted - [ ] Android notification channel created; Android 13+ runtime permission requested
- [ ] APNs key uploaded and FCM v1 credentials configured in EAS
- [ ] Tap handling covers both the running app and cold start
- [ ] Tested on a physical device with a development build, not Expo Go
Where the scaffold comes from
The device-side code above, the token table, and the Supabase wiring are the same in every app. That's the part I let RapidNative generate: it produces the Expo app with expo-notifications, Supabase auth, and the data layer already connected, so the time goes into the sending logic and the receipt job, which are the parts that are actually yours.
What does your receipt-checking setup look like? I've seen everything from a cron job to "we didn't know receipts existed," and I'm curious where most teams land.
Top comments (0)