Tutorial: Sending a Web Push Notification using Web Push API

How to send a web push notification with React, FaunaDB and Netlify functions

Simon du Preez
Simon du Preez
20 February, 2021

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_demo
2cd web_push_demo_notifications_demo
3npm 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';
3
4function App() {
5 return (
6 <div className="App">
7 </div>
8 );
9}
10
11export 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';
3
4const pushEnabled = 'serviceWorker' in navigator && 'PushManager' in window;
5
6function 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}
17
18export 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}
6
7// Ask for permission for push notifications
8export async function askForPushPermission() {
9 // https://developers.google.com/web/fundamentals/push-notifications/subscribing-a-user
10 return new Promise((resolve, reject) => {
11 const permissionResult = Notification.requestPermission(result => {
12 resolve(result);
13 });
14
15 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';
3
4const pushEnabled = 'serviceWorker' in navigator && 'PushManager' in window;
5
6function 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();
11
12 setPermissionState(permissionResult);
13 };
14 // Catch push not enabled
15 if (!pushEnabled) return (
16 <div className="App">
17 <p>Your browser doesn't support push notifications. 😢</p>
18 </div>
19 );
20
21 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});
5
6self.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';
2
3const SERVICE_WORKER_FILE = '/sw.js';
4...
1export async function getServiceWorkerSubscription() {
2 return navigator.serviceWorker
3 .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 * urlBase64ToUint8Array
3 *
4 * @param {string} base64String a public vapid key
5 */
6export function urlB64ToUint8Array(base64String) {
7 const padding = '='.repeat((4 - (base64String.length % 4)) % 4);
8 const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
9
10 const rawData = window.atob(base64);
11 const outputArray = new Uint8Array(rawData.length);
12
13 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.');
4
5 const { vapidPublicKey } = config;
6
7 const subscribeOptions = {
8 userVisibleOnly: true,
9 applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
10 };
11
12 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';
4
5const pushEnabled = 'serviceWorker' in navigator && 'PushManager' in window;
6
7function App() {
8 const handleSubscribeToPushNotifications = async () => {
9 // Step 1: Ask the user for permission.
10 const permissionResult = await askForPushPermission();
11
12 if (permissionResult === PERMISSION_STATES.GRANTED) {
13 const pushSubscription = JSON.parse(
14 JSON.stringify(await subscribeUserToPush())
15 );
16 console.log(pushSubscription);
17 }
18 };
19
20 // Catch push not enabled
21 if (!pushEnabled) return (
22 <div className="App">
23 <p>Your browser doesn't support push notifications. 😢</p>
24 </div>
25 );
26
27
28 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}
36
37export 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.

  1. Create an account at FaunaDb

  2. Create a new database called web_push_demo

  3. Create a new collection called pushSubscriptions

  4. Finally lets create a key to allow our application to access this database:

    1. Go to security and click "New key"

    2. Change the role to "Server"

    3. Give it a name. This should describe who is using the key such as netlify_functions_web_push_demo.

    4. 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.

  1. Install faunadb: npm install --save faunadb

  2. 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:

1Netlify Dev
2Ignored general context env var: LANG (defined in process)
3Injected .env file env var: FAUNADB_SERVER_SECRET
4Injected .env file env var: VAPID_PRIVATE_KEY
5Injected .env file env var: VAPID_PUBLIC_KEY
6Functions server is listening on 65243
7Starting 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';
3
4export 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';
5
6const pushEnabled = 'serviceWorker' in navigator && 'PushManager' in window;
7
8function 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();
13
14 if (permissionResult === PERMISSION_STATES.GRANTED) {
15 // Step 2: Save the push subscription to a database
16 const pushSubscription = await subscribeUserToPush();
17 await savePushSubscription(pushSubscription)
18 }
19 };
20 // Catch push not enabled
21 if (!pushEnabled) return (
22 <div className="App">
23 <p>Your browser doesn't support push notifications. 😢</p>
24 </div>
25 );
26
27 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');
3
4const q = faunadb.query;
5
6// inputPayload = details sent from attheminute-api
7exports.handler = async event => {
8 const refId = JSON.parse(event.body);
9
10 const client = new faunadb.Client({
11 secret: process.env.FAUNADB_SERVER_SECRET,
12 });
13
14 const subscription = await client.query(
15 q.Get(q.Ref(q.Collection("pushSubscriptions"), refId))
16 );
17
18 const pushNotificationPayload = {
19 title: "Hello",
20 body: "World",
21 };
22
23 const pushSubscription = {
24 endpoint: subscription.data.endpoint,
25 keys: {
26 p256dh: subscription.data.keys.p256dh,
27 auth: subscription.data.keys.auth,
28 },
29 };
30
31 // Actually send the push
32 webpush.setVapidDetails(
33 'mailto:simon@attheminute.com',
34 process.env.VAPID_PUBLIC_KEY,
35 process.env.VAPID_PRIVATE_KEY
36 );
37
38 await webpush.sendNotification(
39 pushSubscription,
40 JSON.stringify(pushNotificationPayload)
41 );
42
43 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';
4
5export 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}
20
21export 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';
5
6const pushEnabled = 'serviceWorker' in navigator && 'PushManager' in window;
7
8function App() {
9 const [subscriptionRef, setSubscriptionRef] = React.useState();
10
11 const handleSubscribeToPushNotifications = async () => {
12 // Step 1: Ask the user for permission.
13 const permissionResult = await askForPushPermission();
14
15 if (permissionResult === PERMISSION_STATES.GRANTED) {
16 // Step 2: Save the push subscription to a database
17 const pushSubscription = await subscribeUserToPush();
18 const response = await savePushSubscription(pushSubscription);
19 setSubscriptionRef(response.ref['@ref'].id);
20 }
21 };
22
23 const handleSendPushNotification = async () => {
24 await sendPushNotification(subscriptionRef);
25 };
26
27 // Catch push not enabled
28 if (!pushEnabled) return (
29 <div className="App">
30 <p>Your browser doesn't support push notifications. 😢</p>
31 </div>
32 );
33
34 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});
5
6self.addEventListener('activate', event => {
7 console.log('Service worker activated!');
8});
9
10self.addEventListener('push', event => {
11 const payload = event.data.json();
12
13 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();
4
5 return existingPushSubscription;
6}
7
8export 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.

Comments