PatternsMobile & PWA
Push Notifications
Implement Web Push notifications with service workers
Web Push notifications allow you to re-engage users even when your app isn't open. Used by major apps like Twitter, YouTube, and Facebook.
The Flow
1. User grants permission
2. Browser generates subscription
3. Send subscription to your server
4. Server sends push to browser push service
5. Browser delivers to service worker
6. Service worker shows notificationBasic Setup
Request Permission
// utils/push.ts
export async function requestNotificationPermission(): Promise<boolean> {
if (!('Notification' in window)) {
console.warn('Notifications not supported');
return false;
}
if (Notification.permission === 'granted') {
return true;
}
if (Notification.permission === 'denied') {
console.warn('Notification permission denied');
return false;
}
const permission = await Notification.requestPermission();
return permission === 'granted';
}Subscribe to Push
// Get VAPID public key from your server
const VAPID_PUBLIC_KEY = 'YOUR_PUBLIC_KEY';
export async function subscribeToPush(): Promise<PushSubscription | null> {
const permission = await requestNotificationPermission();
if (!permission) return null;
const registration = await navigator.serviceWorker.ready;
try {
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY),
});
// Send subscription to your server
await sendSubscriptionToServer(subscription);
return subscription;
} catch (error) {
console.error('Failed to subscribe:', error);
return null;
}
}
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
async function sendSubscriptionToServer(subscription: PushSubscription) {
await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(subscription),
});
}Service Worker - Receive Push
// sw.js
self.addEventListener('push', (event) => {
if (!event.data) return;
const data = event.data.json();
const options = {
body: data.body,
icon: data.icon || '/icon.png',
badge: '/badge.png',
vibrate: [200, 100, 200],
data: {
url: data.url || '/',
timestamp: Date.now(),
},
actions: data.actions || [],
tag: data.tag || 'default',
renotify: true,
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});
// Handle notification click
self.addEventListener('notificationclick', (event) => {
event.notification.close();
// Handle action buttons
if (event.action) {
console.log('Action clicked:', event.action);
// Handle specific action
}
// Open URL
event.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
// Check if app is already open
for (const client of clientList) {
if (client.url === event.notification.data.url && 'focus' in client) {
return client.focus();
}
}
// Open new window
if (clients.openWindow) {
return clients.openWindow(event.notification.data.url);
}
})
);
});Server-Side (Node.js)
Generate VAPID Keys
npm install web-push// scripts/generate-vapid-keys.js
const webpush = require('web-push');
const vapidKeys = webpush.generateVAPIDKeys();
console.log('Public Key:', vapidKeys.publicKey);
console.log('Private Key:', vapidKeys.privateKey);
// Save to .env:
// VAPID_PUBLIC_KEY=...
// VAPID_PRIVATE_KEY=...
// VAPID_EMAIL=mailto:your-email@example.comSend Push Notification
// api/push/send.ts
import webpush from 'web-push';
webpush.setVapidDetails(
process.env.VAPID_EMAIL!,
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
export async function sendPushNotification(
subscription: PushSubscription,
payload: any
) {
try {
await webpush.sendNotification(subscription, JSON.stringify(payload));
return { success: true };
} catch (error) {
console.error('Push failed:', error);
// If subscription is invalid, remove from database
if (error.statusCode === 410) {
await removeSubscription(subscription);
}
return { success: false, error };
}
}Store Subscriptions
// api/push/subscribe.ts
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
const subscription = await request.json();
const userId = request.headers.get('user-id');
// Save subscription to database
await db.pushSubscriptions.create({
data: {
userId,
endpoint: subscription.endpoint,
keys: subscription.keys,
},
});
return NextResponse.json({ success: true });
}Advanced Notifications
Rich Notifications
// sw.js
self.addEventListener('push', (event) => {
const data = event.data.json();
const options = {
body: data.body,
icon: '/icon-192x192.png',
badge: '/badge-72x72.png',
image: data.image, // Large image
vibrate: [200, 100, 200, 100, 200],
// Action buttons
actions: [
{
action: 'like',
title: 'š Like',
icon: '/icons/like.png',
},
{
action: 'reply',
title: 'š¬ Reply',
icon: '/icons/reply.png',
},
],
// Custom data
data: {
url: data.url,
postId: data.postId,
},
// Notification behavior
requireInteraction: false, // Auto-dismiss
silent: false,
tag: data.tag, // Replace previous notification with same tag
renotify: true, // Vibrate even if replacing
timestamp: Date.now(),
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
});Inline Reply
self.addEventListener('notificationclick', (event) => {
event.notification.close();
if (event.action === 'reply') {
// Show inline reply input
event.waitUntil(
clients.openWindow(`/reply?postId=${event.notification.data.postId}`)
);
} else if (event.action === 'like') {
// Like post in background
event.waitUntil(
fetch('/api/posts/like', {
method: 'POST',
body: JSON.stringify({ postId: event.notification.data.postId }),
})
);
}
});Notification Badges
// Update app icon badge
navigator.setAppBadge(5); // Show "5"
navigator.clearAppBadge(); // Clear badgeReact Components
Permission Button
// components/NotificationButton.tsx
import { useState, useEffect } from 'react';
import { requestNotificationPermission, subscribeToPush } from '@/utils/push';
export function NotificationButton() {
const [permission, setPermission] = useState<NotificationPermission>('default');
const [subscribed, setSubscribed] = useState(false);
useEffect(() => {
if ('Notification' in window) {
setPermission(Notification.permission);
}
}, []);
const handleSubscribe = async () => {
const granted = await requestNotificationPermission();
if (granted) {
const subscription = await subscribeToPush();
setSubscribed(!!subscription);
setPermission('granted');
} else {
setPermission(Notification.permission);
}
};
if (permission === 'denied') {
return (
<div className="notification-blocked">
<p>Notifications blocked. Enable in browser settings.</p>
</div>
);
}
if (permission === 'granted' && subscribed) {
return (
<div className="notification-enabled">
<p>ā Notifications enabled</p>
</div>
);
}
return (
<button onClick={handleSubscribe}>
š Enable Notifications
</button>
);
}Notification Prompt
// components/NotificationPrompt.tsx
import { useState, useEffect } from 'react';
export function NotificationPrompt() {
const [show, setShow] = useState(false);
useEffect(() => {
// Show prompt after 30 seconds if not already decided
const timer = setTimeout(() => {
if (Notification.permission === 'default') {
setShow(true);
}
}, 30000);
return () => clearTimeout(timer);
}, []);
if (!show) return null;
return (
<div className="notification-prompt">
<p>Get notified of important updates</p>
<button onClick={async () => {
await requestNotificationPermission();
setShow(false);
}}>
Enable Notifications
</button>
<button onClick={() => setShow(false)}>
Not Now
</button>
</div>
);
}Real-World Examples
Chat Notification
// Send from server
await sendPushNotification(userSubscription, {
title: 'New message from John',
body: 'Hey, how are you?',
icon: '/avatars/john.jpg',
badge: '/badge.png',
tag: 'chat-john',
data: {
url: '/chat/john',
chatId: '123',
},
actions: [
{ action: 'reply', title: 'Reply' },
{ action: 'mark-read', title: 'Mark as read' },
],
});E-commerce Order Update
await sendPushNotification(userSubscription, {
title: 'Order Shipped! š¦',
body: 'Your order #12345 is on its way',
icon: '/logo.png',
image: '/products/order-12345.jpg',
tag: 'order-12345',
data: {
url: '/orders/12345',
orderId: '12345',
},
actions: [
{ action: 'track', title: 'Track Package' },
{ action: 'details', title: 'Order Details' },
],
});Breaking News
await sendPushNotification(userSubscription, {
title: 'Breaking News',
body: 'Important announcement...',
icon: '/icon.png',
badge: '/badge.png',
tag: 'breaking-news',
requireInteraction: true, // Don't auto-dismiss
vibrate: [500, 200, 500],
data: {
url: '/news/breaking',
},
});Testing
// Test notification locally
if ('Notification' in window && Notification.permission === 'granted') {
new Notification('Test Notification', {
body: 'This is a test',
icon: '/icon.png',
});
}
// Test push with curl
curl -X POST https://fcm.googleapis.com/fcm/send \
-H "Authorization: key=SERVER_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": "DEVICE_TOKEN",
"notification": {
"title": "Test",
"body": "Test message"
}
}'Best Practices
- Ask at right time: Not immediately on page load
- Explain value: Tell users why notifications are useful
- Respect denial: Don't ask again if denied
- Group notifications: Use tags to replace similar notifications
- Actionable: Include relevant actions
- Timely: Send at appropriate times
- Relevant: Only send important updates
- Test thoroughly: Different devices and browsers
- Handle errors: Remove invalid subscriptions
- Privacy: Don't send sensitive data in notifications
Common Pitfalls
ā Asking immediately: Annoys users
ā
Wait for engagement, explain value
ā No action buttons: Missed engagement
ā
Add relevant actions (like, reply, etc.)
ā Spam: Too many notifications
ā
Only send important, relevant updates
ā Generic messages: "New update"
ā
Specific, actionable content
ā No error handling: Stale subscriptions
ā
Remove invalid subscriptions (410 errors)
Push notifications are powerful for re-engagementāuse them wisely and respect user preferences!