How to send web push notifications
This tutorial will take you through the process of sending a web push notification to a user's device. I have focused more on what to do rather than the technical why. I'll be writing a follow up article which will be more comprehensive on how web push works.
The full code can be found here: https://github.com/SDupZ/web_push_notifications_demo.
A demo of what we will be building is deployed here: https://lucid-darwin-ed4672.netlify.app/
Overview
These are the tools we'll be using:
create-react-app (used to bootstrap a React project for our frontend)
Netlify functions (used as our backend system)
Netlify (used to host our demo)
FaunaDB (used to persist our users Push Subscriptions)
Netlify dev (used to run our netlify functions locally)
This tutorial uses create-react-app as a base project to demonstrate how to send a web push notification.
10,000 foot diagram of what we're building
Setup
Bootstrap a new project with create-react-app.
1npx create-react-app web_push_demo_notifications_demo2cd web_push_demo_notifications_demo3npm start
Remove the extra code we don't need from App.js
. This is what our file should look like:
1import React from 'react';2import './App.css';34function App() {5 return (6 <div className="App">7 </div>8 );9}1011export default App;12
Step 1: Check if Push notifications can be sent to the current browser
We are using the experimental PushSubscription API which is not supported in some browsers. Notably Safari and IE11. So we first need to make sure the user's browser is suitable:
const pushEnabled = 'serviceWorker' in navigator && 'PushManager' in window;
The full snippet:
1import React from 'react';2import './App.css';34const pushEnabled = 'serviceWorker' in navigator && 'PushManager' in window;56function App() {7 return (8 <div className="App">9 {pushEnabled ? (10 <p>Your browser supports web push! Yay! 🚀</p>11 ) : (12 <p>Your browser doesn't support push notifications. 😢</p>13 )}14 </div>15 );16}1718export default App;19
Step 2: Ask for permission to sign up to web push notifications
In order for us to send a push notification, the user must first consent to allow them.
Create the file utils.js
in your ./src
directory. Then create a util function which is responsible for initiating the consent flow.
1export const PERMISSION_STATES = {2 UNKNOWN: 0,3 GRANTED: 1,4 DENIED: 2,5}67// Ask for permission for push notifications8export async function askForPushPermission() {9 // https://developers.google.com/web/fundamentals/push-notifications/subscribing-a-user10 return new Promise((resolve, reject) => {11 const permissionResult = Notification.requestPermission(result => {12 resolve(result);13 });1415 if (permissionResult) {16 permissionResult.then(resolve, reject);17 }18 }).then(permissionResult => {19 if (permissionResult !== 'granted') {20 return PERMISSION_STATES.DENIED;21 }22 return PERMISSION_STATES.GRANTED;23 });24}
Ask for permission for push notifications:
1import React from 'react';2import './App.css';34const pushEnabled = 'serviceWorker' in navigator && 'PushManager' in window;56function App() {7 const [, setPermissionState] = React.useState(PERMISSION_STATES.UNKNOWN);8 const handleSubscribeToPushNotifications = async () => {9 // Step 1: Ask the user for permission.10 const permissionResult = await askForPushPermission();1112 setPermissionState(permissionResult);13 };14 // Catch push not enabled15 if (!pushEnabled) return (16 <div className="App">17 <p>Your browser doesn't support push notifications. 😢</p>18 </div>19 );2021 return (22 <div className="App">23 <p>Your browser supports web push! Yay! 🚀</p>24 <p>Click the button to receive article updates through web push notifications.</p>25 <button onClick={handleSubscribeToPushNotifications}>Sign up!</button>26 </div>27 );28}29export default App;30
Step 3: Create a push subscription
3.1: The service worker file
Create a file sw.js
. in the public
directory (e.g: public/sw.js
). Add the following:
1self.addEventListener('install', event => {2 self.skipWaiting();3 console.log('Installing…');4});56self.addEventListener('activate', event => {7 console.log('Service worker activated!');8});
3.2 Generate a VAPID key pair
Create a new file config.js
in your src
directory. You will need to generate your own public key.
1export default {2 vapidPublicKey: 'BPAzNOCBXempD0rvCjfgUxiKIrvtCwUW4AzoGHrjLBV2bVXoFSU1M-cbW6wyJwRgSDPcB0Zi9jUKgRL7fHJbvRU',3};
To generate a pair of private and public VAPID keys you can use this tool here:
https://www.attheminute.com/vapid-key-generator/
Copy your public key into the config. Additionally create a .env
file in the root of your project and copy the private and public keys in as following:
1VAPID_PRIVATE_KEY=<YOUR_PRIVATE_KEY>2VAPID_PUBLIC_KEY=<YOUR_PUBLIC_KEY>
If you lose it that's fine, you can simply generate another pair.
3.3 Create a subscription
We need some util functions which will help us create a push subscription.
In your utils.js
file add the following functions:
1import config from './config';23const SERVICE_WORKER_FILE = '/sw.js';4...
1export async function getServiceWorkerSubscription() {2 return navigator.serviceWorker3 .register(SERVICE_WORKER_FILE)4 .then(registration => {5 console.log(6 'Service worker registered! Waiting for it to become active...'7 );8 const serviceWorker =9 registration.installing || registration.waiting || registration.active;10 let whenRegistrationActive = Promise.resolve(registration);11 if (!registration.active || registration.active.state !== 'activated') {12 whenRegistrationActive = new Promise(resolve => {13 serviceWorker.addEventListener('statechange', e => {14 if (e.target.state === 'activated') {15 resolve(registration);16 }17 });18 });19 }20 return whenRegistrationActive;21 });22}
1/**2 * urlBase64ToUint8Array3 *4 * @param {string} base64String a public vapid key5 */6export function urlB64ToUint8Array(base64String) {7 const padding = '='.repeat((4 - (base64String.length % 4)) % 4);8 const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');910 const rawData = window.atob(base64);11 const outputArray = new Uint8Array(rawData.length);1213 for (let i = 0; i < rawData.length; i += 1) {14 outputArray[i] = rawData.charCodeAt(i);15 }16 return outputArray;17}
1export async function subscribeUserToPush() {2 return getServiceWorkerSubscription().then(registration => {3 console.log('Service worker active! Ready to go.');45 const { vapidPublicKey } = config;67 const subscribeOptions = {8 userVisibleOnly: true,9 applicationServerKey: urlB64ToUint8Array(vapidPublicKey),10 };1112 return registration.pushManager.subscribe(subscribeOptions);13 });14}
3.4 Wire it up to our React code
1import React from 'react';2import { PERMISSION_STATES, askForPushPermission, subscribeUserToPush } from './utils';3import './App.css';45const pushEnabled = 'serviceWorker' in navigator && 'PushManager' in window;67function App() {8 const handleSubscribeToPushNotifications = async () => {9 // Step 1: Ask the user for permission.10 const permissionResult = await askForPushPermission();1112 if (permissionResult === PERMISSION_STATES.GRANTED) {13 const pushSubscription = JSON.parse(14 JSON.stringify(await subscribeUserToPush())15 );16 console.log(pushSubscription);17 }18 };1920 // Catch push not enabled21 if (!pushEnabled) return (22 <div className="App">23 <p>Your browser doesn't support push notifications. 😢</p>24 </div>25 );262728 return (29 <div className="App">30 <p>Your browser supports web push! Yay! 🚀</p>31 <p>Click the button to receive article updates through web push notifications.</p>32 <button onClick={handleSubscribeToPushNotifications}>Sign up!</button>33 </div>34 );35}3637export default App;
3.5 Run it!
If all goes well, once you click the button to allow push notifications, your console should look like this:
1Installing…2Service worker registered! Waiting for it to become active...3Service worker activated!4Service worker active! Ready to go.
Additionally you should have a JSON object with your subscription which will look something like:
"{endpoint: "https://fcm.googleapis.com/fcm/sen...."
This is the push subscription generated for your user which we will later use to send the actual push notification.
Step 4: Saving the push subscription
We will be using Netlify functions which are a convenient wrapper around lambdas to run our backend logic. For storing the subscriptions we will be using FaunaDB.
4.1 Create our database
Firstly lets set up FaunaDB to save the push subscription.
Create an account at FaunaDb
Create a new database called
web_push_demo
Create a new collection called
pushSubscriptions
Finally lets create a key to allow our application to access this database:
Go to security and click "New key"
Change the role to "Server"
Give it a name. This should describe who is using the key such as
netlify_functions_web_push_demo
.Add the key you see to your
.env
file:
1FAUNADB_SERVER_SECRET=<PASTE_YOUR_KEY_HERE>
4.2 Create a lambda function to save the subscription
The lambda function will handle talking to FaunaDb.
Install faunadb:
npm install --save faunadb
Create the the file:
netlify/functions/savePushSubscription.js
(you'll need to create the netlify + functions folder) and then add the following:
1/* Import faunaDB sdk */2const faunadb = require('faunadb');3const q = faunadb.query;4exports.handler = async event => {5 const client = new faunadb.Client({6 secret: process.env.FAUNADB_SERVER_SECRET,7 });8 const pushSubscription = JSON.parse(event.body);9 try {10 const response = await client.query(11 q.Create(q.Collection('pushSubscriptions'), { data: pushSubscription })12 );13 return {14 statusCode: 200,15 body: JSON.stringify(response),16 };17 } catch (error) {18 console.error(error);19 return {20 statusCode: 500,21 body: JSON.stringify(error),22 };23 }24};
4.3 Run it locally with Netlify lambda
We've set up our function, but in order to run it we need to set the FAUNADB secret and use netlify-lambda to run our lambda locally.
npm install netlify-lambda
Install netlify dev:
npm install netlify-cli -g
Add this to your package.json underneath the scripts
block:
"start-netlify": "netlify dev",
Run your project with:npm run start-netlify
If everything has gone well you should see a console message with the following:
1◈ Netlify Dev ◈2◈ Ignored general context env var: LANG (defined in process)3◈ Injected .env file env var: FAUNADB_SERVER_SECRET4◈ Injected .env file env var: VAPID_PRIVATE_KEY5◈ Injected .env file env var: VAPID_PUBLIC_KEY6◈ Functions server is listening on 652437◈ Starting Netlify Dev with create-react-app
🚨Make sure you see that FAUNADB_SERVER_SECRET
has been injected properly from your .env
file. 🚨
4.4: Create your API service call
Create a new file api.js
in the src
directory and add the following:
1const apiHostname = `${window.location.protocol}//${window.location.host}`;2const SAVE_PUSH_SUBSCRIPTION_PATH = '/.netlify/functions/savePushSubscription';34export async function savePushSubscription(subscription) {5 const url = `${apiHostname}${SAVE_PUSH_SUBSCRIPTION_PATH}`;6 const response = await fetch(url, {7 method: 'POST',8 mode: 'same-origin',9 cache: 'no-cache',10 credentials: 'same-origin',11 headers: {12 'Content-Type': 'application/json'13 },14 referrerPolicy: 'no-referrer',15 body: JSON.stringify(subscription),16 });17 return response.json();18}
4.5. Wire it up to your frontend
In App.js
:
1import React from 'react';2import { savePushSubscription } from './api';3import { PERMISSION_STATES, askForPushPermission, subscribeUserToPush } from './utils';4import './App.css';56const pushEnabled = 'serviceWorker' in navigator && 'PushManager' in window;78function App() {9 const [, setPermissionState] = React.useState(PERMISSION_STATES.UNKNOWN);10 const handleSubscribeToPushNotifications = async () => {11 // Step 1: Ask the user for permission.12 const permissionResult = await askForPushPermission();1314 if (permissionResult === PERMISSION_STATES.GRANTED) {15 // Step 2: Save the push subscription to a database16 const pushSubscription = await subscribeUserToPush();17 await savePushSubscription(pushSubscription)18 }19 };20 // Catch push not enabled21 if (!pushEnabled) return (22 <div className="App">23 <p>Your browser doesn't support push notifications. 😢</p>24 </div>25 );2627 return (28 <div className="App">29 <p>Your browser supports web push! Yay! 🚀</p>30 <p>Click the button to receive article updates through web push notifications.</p>31 <button onClick={handleSubscribeToPushNotifications}>Sign up!</button>32 </div>33 );34}35export default App;36
4.6. Try it out!
Re run your project with npm run start-netlify
.
If everything has gone well to this point if you click the signup button a new entry should have been created in FaunaDB. Great!
5. Finally send a push notification! 🎉
We're nearly there. There's just a few mores step to actually send the push notification.
We will be using the web-push library.
npm install --save web-push
Create a new file called sendPushNotifcation
in netlify/functions/sendPushNotification.js
and then add the following:
1const faunadb = require('faunadb');2const webpush = require('web-push');34const q = faunadb.query;56// inputPayload = details sent from attheminute-api7exports.handler = async event => {8 const refId = JSON.parse(event.body);910 const client = new faunadb.Client({11 secret: process.env.FAUNADB_SERVER_SECRET,12 });1314 const subscription = await client.query(15 q.Get(q.Ref(q.Collection("pushSubscriptions"), refId))16 );1718 const pushNotificationPayload = {19 title: "Hello",20 body: "World",21 };2223 const pushSubscription = {24 endpoint: subscription.data.endpoint,25 keys: {26 p256dh: subscription.data.keys.p256dh,27 auth: subscription.data.keys.auth,28 },29 };3031 // Actually send the push32 webpush.setVapidDetails(33 'mailto:simon@attheminute.com',34 process.env.VAPID_PUBLIC_KEY,35 process.env.VAPID_PRIVATE_KEY36 );3738 await webpush.sendNotification(39 pushSubscription,40 JSON.stringify(pushNotificationPayload)41 );4243 return {44 statusCode: 200,45 body: 'ok',46 };47};
Update your api.js
to call this new function:
1const apiHostname = `${window.location.protocol}//${window.location.host}`;2const SAVE_PUSH_SUBSCRIPTION_PATH = '/.netlify/functions/savePushSubscription';3const SEND_PUSH_NOTIFICATION_PATH = '/.netlify/functions/sendPushNotification';45export async function savePushSubscription(subscription) {6 const url = `${apiHostname}${SAVE_PUSH_SUBSCRIPTION_PATH}`;7 const response = await fetch(url, {8 method: 'POST',9 mode: 'same-origin',10 cache: 'no-cache',11 credentials: 'same-origin',12 headers: {13 'Content-Type': 'application/json'14 },15 referrerPolicy: 'no-referrer',16 body: JSON.stringify(subscription),17 });18 return response.json();19}2021export async function sendPushNotification(id) {22 const url = `${apiHostname}${SEND_PUSH_NOTIFICATION_PATH}`;23 const response = await fetch(url, {24 method: 'POST',25 mode: 'same-origin',26 cache: 'no-cache',27 credentials: 'same-origin',28 headers: {29 'Content-Type': 'application/json'30 },31 referrerPolicy: 'no-referrer',32 body: JSON.stringify(id),33 });34}35
Wire this up to your frontend by updating App.js
1import React from 'react';2import { savePushSubscription, sendPushNotification } from './api';3import { PERMISSION_STATES, askForPushPermission, subscribeUserToPush } from './utils';4import './App.css';56const pushEnabled = 'serviceWorker' in navigator && 'PushManager' in window;78function App() {9 const [subscriptionRef, setSubscriptionRef] = React.useState();1011 const handleSubscribeToPushNotifications = async () => {12 // Step 1: Ask the user for permission.13 const permissionResult = await askForPushPermission();1415 if (permissionResult === PERMISSION_STATES.GRANTED) {16 // Step 2: Save the push subscription to a database17 const pushSubscription = await subscribeUserToPush();18 const response = await savePushSubscription(pushSubscription);19 setSubscriptionRef(response.ref['@ref'].id);20 }21 };2223 const handleSendPushNotification = async () => {24 await sendPushNotification(subscriptionRef);25 };2627 // Catch push not enabled28 if (!pushEnabled) return (29 <div className="App">30 <p>Your browser doesn't support push notifications. 😢</p>31 </div>32 );3334 return (35 <div className="App">36 <p>Your browser supports web push! Yay! 🚀</p>37 <p>Click the button to receive article updates through web push notifications.</p>38 <button onClick={handleSubscribeToPushNotifications}>Sign up!</button>39 {subscriptionRef ? (40 <>41 <p>Send a push notification!</p>42 <button onClick={handleSendPushNotification}>Send myself push notification!</button>43 </>44 ) : null}45 </div>46 );47}48export default App;49
Finally the last step is to instruct the service worker to trigger the push notification. Inside your public/sw.js
:
1self.addEventListener('install', event => {2 self.skipWaiting();3 console.log('Installing…');4});56self.addEventListener('activate', event => {7 console.log('Service worker activated!');8});910self.addEventListener('push', event => {11 const payload = event.data.json();1213 const promiseChain = self.registration.showNotification(payload.title, {14 body: payload.body,15 icon: payload.icon,16 silent: true,17 data: { clickTarget: payload.clickTarget },18 });19 event.waitUntil(promiseChain);20});
🎉🎉🎉 Success 🎉🎉🎉
If everything has been set up correctly so far, when you click the "send push notification" button you should receive a notification!
Troubleshooting
Most commonly you'll want to reset your browser permissions and then reload the page. You can do this by following the steps listed in section 3.5. For any other questions feel free to message: simon@attheminute.com
Further improvements
Customize your notification to show icons, images or many other features in the specification
Allow users to unsubscribe from notifications
Detect existing subscriptions and apply different logic for a better UX.
Only create one FaunaDB entry for a single push subscription. (We are currently creating a new entry each time)
This snippet may be useful for you if you want to implement some of the above:
1export async function getExistingPushSubscription() {2 const registration = await getServiceWorkerSubscription();3 const existingPushSubscription = await registration.pushManager.getSubscription();45 return existingPushSubscription;6}78export async function unsubscribeUserToPush() {9 const pushSubscription = await getExistingPushSubscription();10 const result = await pushSubscription.unsubscribe();11 return result;12}
If you made it this far thanks for reading!
For any support or feedback feel free to reach out at simon@attheminute.com or via twitter.