Local notifications work out of the box. Remote push requires APNs (iOS) and FCM (Android) credentials.
iOS Setup (APNs)
EAS Build handles APNs certificates automatically. Just ensure:
- Push Notifications capability is enabled in Apple Developer Portal
app.json includes:
{
"ios": {
"infoPlist": {
"UIBackgroundModes": ["remote-notification"]
}
}
}
Build with eas build --platform ios.
Android Setup (FCM)
-
Create a Firebase project at console.firebase.google.com
-
Add your Android app (use package name from
app.json)
-
Download
google-services.json to apps/app/
-
For server-side sending, set up FCM credentials in your backend:
Supabase: Upload your Firebase service account JSON in the Supabase Dashboard under Integrations → Firebase.
Convex: Add your Firebase service account JSON as an environment variable in the Convex Dashboard.
The legacy FCM Server Key is deprecated. Firebase now uses HTTP v1 API with OAuth 2.0 service account authentication. All server credentials should be configured in your backend dashboard, not in client-side environment variables.
See Expo Push Notifications for detailed setup.
Usage
Request Permission
import { useNotificationStore } from '@/stores/notificationStore'
function EnableNotifications() {
const { requestPermission, permissionStatus } = useNotificationStore()
const handleEnable = async () => {
const granted = await requestPermission()
// If denied, an alert prompts user to open Settings
}
return permissionStatus === 'undetermined' && (
<Button onPress={handleEnable} title="Enable Notifications" />
)
}
Toggle Push Notifications
const { togglePush, isPushEnabled } = useNotificationStore()
// Toggle on/off - handles permission request automatically
await togglePush(user?.id) // Pass userId to sync preference to backend
Schedule Local Notification
import { scheduleNotification } from '@/services/notifications'
await scheduleNotification({
title: 'Reminder',
body: 'Time to check your progress!',
data: { screen: 'Profile' },
trigger: { seconds: 3600 },
})
Send Remote Notification (Backend)
Get the push token from your app:
const { pushToken } = useNotificationStore()
// Send pushToken to your backend
Send from Node.js backend:
import { Expo } from 'expo-server-sdk'
const expo = new Expo()
await expo.sendPushNotificationsAsync([{
to: pushToken,
title: 'New Message',
body: 'You have a new message',
data: { screen: 'Messages' },
}])
See Expo Push Service.
Handle Notification Taps
The notification store handles this automatically via initialize(). For custom handling:
import { addNotificationResponseReceivedListener } from '@/services/notifications'
import { useNavigation } from '@react-navigation/native'
useEffect(() => {
const subscription = addNotificationResponseReceivedListener(response => {
const { screen } = response.notification.request.content.data
if (screen) navigation.navigate(screen)
})
return () => subscription.remove()
}, [])
Automatic Initialization
The notification store initializes automatically on app startup:
- Requests permission status
- Registers for push tokens (on physical devices)
- Sets up notification listeners
- Handles token refresh when tokens are invalidated
- Configures Android notification channels
No manual setup required - just use the store.
API Reference
Notification Store
const {
permissionStatus, // 'granted' | 'denied' | 'undetermined' | 'loading'
pushToken, // Expo push token for remote notifications
isPushEnabled, // User preference toggle
notifications, // Array of received notifications (last 50)
unreadCount, // Number of unread notifications
// Actions
initialize, // Called automatically on app start
cleanup, // Called automatically on app unmount
requestPermission, // Request OS permission
togglePush, // Toggle notifications on/off
scheduleNotification, // Schedule local notification
cancelAllNotifications,
setBadgeCount,
markAsRead,
markAllAsRead,
} = useNotificationStore()
Notification Service
import {
requestPermission,
registerForPushNotifications,
scheduleNotification,
cancelNotification,
addNotificationReceivedListener,
addNotificationResponseReceivedListener,
addPushTokenListener,
showPermissionDeniedAlert,
isPhysicalDevice,
} from '@/services/notifications'
Features
Physical Device Detection
Push tokens only work on physical devices. The service automatically detects emulators/simulators and returns mock tokens for development.
import { isPhysicalDevice } from '@/services/notifications'
if (!isPhysicalDevice()) {
console.log('Running on emulator - using mock push token')
}
Token Refresh Handling
Push tokens can be invalidated by the OS. The store automatically listens for token changes and re-registers when needed.
Android Notification Channels
Android 8.0+ requires notification channels. Shipnative configures a default channel automatically with:
- Maximum importance (ensures notifications appear prominently)
- Vibration pattern
- Sound enabled
- Badge support
- Show on lockscreen
Channels are created automatically on first notification. No manual configuration needed.
Permission Denied Alerts
When users deny permission, a helpful alert offers to open Settings:
import { showPermissionDeniedAlert } from '@/services/notifications'
// Called automatically by togglePush() and requestPermission()
// Or call manually:
showPermissionDeniedAlert()
The alert includes:
- Clear explanation of what permission enables
- “Open Settings” button (deep links to app settings)
- “Maybe Later” option
- Platform-specific instructions (iOS vs Android)
Backend Synchronization
Push token is automatically synced to your backend:
// In services/notifications.ts - automatically called
const { pushToken } = useNotificationStore()
// Token is synced to:
// - Supabase: push_tokens table with user association
// - Convex: user.pushToken field
// - Or custom backend via env.backendUrl/push-tokens
// Backend can use token to send notifications
await fetch(`${backendUrl}/notifications/send`, {
method: 'POST',
body: JSON.stringify({
token: pushToken,
title: 'New Message',
body: 'You have a new message!',
})
})
Notification Badge Management
Control app badge counts on iOS and Android:
const { setBadgeCount, unreadCount } = useNotificationStore()
// Set badge to unread count
setBadgeCount(unreadCount)
// Clear badge
setBadgeCount(0)
// Automatically cleared when marking notifications as read
markAllAsRead() // Also clears badge
Analytics Integration
Notification events are automatically tracked with PostHog:
// Automatically tracked:
// - notification_permission_requested
// - notification_permission_granted
// - notification_permission_denied
// - notification_received (foreground)
// - notification_tapped (user interaction)
// - notification_scheduled (local)
// Events include metadata:
{
title: 'Notification title',
hasData: true,
screen: 'HomeScreen', // If data includes screen
platform: 'ios' | 'android' | 'web'
}
Limitations
- Web: Push tokens return
null. Remote push via Expo not supported on web.
- Emulators: Remote push doesn’t work. Mock tokens returned for development.
Troubleshooting
Notifications not appearing?
- Check
permissionStatus is 'granted'
- For iOS remote: use physical device, verify APNs setup
- For Android: verify
google-services.json is in place
Push token is null?
- Expected on web platform
- On mobile: check permission was granted first
- On emulator: mock token is returned (check logs)
Permission denied?
- The store shows an alert prompting users to open Settings
- Check
permissionStatus for current state
Token changed unexpectedly?
- Normal behavior - tokens can be invalidated by the OS
- The store handles this automatically via
addPushTokenListener
Next Steps