Web Push Notifications in Gatsby.
How I added notifications to this Gatsby blog using service workers and Netlify functions.
March 15, 2020 | 8 min. read
Push notifications are an awesome addition to the web (when not abused). It lets web apps send notifications to devices as if they’re natively installed apps! Gatsby on the other hand has turned building static sites into a pretty impressive ecosystem. Bringing push notifications into Gatsby is a natural next step for any modern site. You probably already know all this, and just want to know how, so I’ll jump right into it.
I went from thinking implementing push notifications in Gatsby would be easy (after all, enabling offline support only took adding a plugin) -
to thinking it would be hopelessly difficult, after searching for an offline plugin, not finding one,
seeing this issue⧉ and thinking I would have to rewrite a new service worker - to realizing it’s actually doable.
All I had to do was append to the service-worker that is generated by gatbsy-plugin-offline
⧉.
This was an “ah-ha” 💡 moment for me.
The option I needed (and missed for a long time) is appendScript
which points to a javascript file that gets added to the service worker code.
So, you can keep the full offline feature the plugin provides and just build on top of that.
I used this guide⧉ to get a better understanding of implementing push notifications.
I highly recommend atleast skimming through that as building out a push notification system involves an end-to-end understanding of the process.
Some sections of this post have been edited since original posting. This post is not a step-by-step guide and assumes comfort with a Gatsby; and a project that’s already set up including plugin(s) referenced. For a full look-through of the code, there’s a link to this site’s source at the end of the post.
Here is everything I created to get push notifications running,
- A React component that lets users control subscription (the button at the bottom of every page on this blog).
- Database for storing subscriptions (add/get/delete).
- A Netlify function that listens for subscriptions/unsubscriptions and another for sending push notification to subscribers.
- Service worker that listens for and sends notifications to the user’s device.
Push notification React component
Even though implementing push notifications is (probably equally) a backend and front end endeavor, I ended up spending a lot of time creating this UI component. If you have read my previous posts, you’ll know that I strongly oppose dark patterns (well, I suppose most people do). I definitely wanted to do it right, so it was important to make unsubscribing as easy as subscribing - and I took my time to make sure I accomplished this. The component itself is a button that lies in the footer of the page. The basic flow when the button is clicked is:
-
If the browser supports push notifications:
- request permission to send notifications, if permitted:
- create a new subscription and send the subscription data to the backend
- change the button to allow the user to unsubscribe.
- if request not permitted, show an error alert.
- if the browser doesn’t support push notifications, then nothing shows up.
On Safari or any other browser that does not support the Push API, the component returns null
and nothing renders. There’s one caveat that in Gatsby, “window” isn’t defined at build time (build runs in node) so I had to wrap this check inside another check like so:
//PushNotification.js
...
if (typeof window !== `undefined`) {
if ("PushManager" in window) {
return true
}
}
...
Subscribing and unsubscribing simply requires making calls to : swRegistration.pushManager.subscribe
and subscription.unsubscribe
respectively.
Once subscribed, the browser returns a subscription object containing the endpoint url and all extra data required to send encrypted notifications to that url.
And saving the subscription is a POST
call to the subscription endpoint.
I will go over that in the section for the Netlify functions.
To persist subscription state (closed tab or even browser), a flag gets stored in localStorage
to specify wether the user is subscribed or not.
The value of the flag is used to determine the React state of the subscription button.
I used a flag instead of storing subscription information to ensure I was storing the least amount of data (requesting a subscription from the browser returns the same subscription endpoint url if there’s an existing one, so there’s no need to store that url).
For notifying on the status of the subscription, I decided to use react-notify-toast
.
It’s very basic and does exactly what its name suggest.
This creates toast notifications that show up in the web page itself (so a DOM element), not browser notifications, a bit confusing, I know.
It looks something like this:
Update (June-06-2020): After realizing the actual experience of subscribing to push notifications and getting a toast but not a push notification is a little jarring, I have now made updates that will send an actual push notification to the user’s browser to confirm subscription. I’d avoided this to not annoy people with unnecessary pings, but it definitely fits in this scenario. The toasts described above are still shown since I need a way to provide feedback on push subscription status even if web push is not available.
Database
There are many options for a database for storing subscriptions.
I used FaunaDB and it worked out fine.
I just recently migrated to Hasura at hasura.io.
I currently use Cloudflare’s workers KV.
Consider these for a database:
- FaunaDB
- Hasura + heroku
- Google cloud firestore
- dynamodb
- Workers KV
Really, any kind of persistent data store that can satisfy the following will suffice:
- Storing the subscription data,
- fetching all subscriptions (when sending notifications)
- pulling an individual subscription by its id.
The shape (JavaScript object) of the subscriptions is:
{
endpoint: '<...>',
expirationTime: null,
keys: {
p256dh: '<...>',
auth: '<...>'
}
}
The subscription is stored as-is. This is easy since postgres supports json/jsonb. Storing a json string is also valid. This is what I was doing with FaunaDB.
The endpoint
is always unique for each subscription (per browser);
it’s perfect as the primary key/id.
A unique ID is necessary for finding and deleting subscriptions when a user unsubscribes.
In my case, the table storing subscriptions essentially has two columns - one for the subscription data itself (json) and a column for the endpoint.
(Lambda Powered) Netlify Functions
I created two Netlify functions (push-subscription⧉ and push-notification⧉ ). Netlify functions are basically AWS Lambda functions so if you have experience with that, it’s the same. Create an event handler for HTTP requests -> Process the event -> Send a response.
I use a package called graph-request
for making graphql requests to hasura, same as I did with Fauna.
So all I needed was respond to POST and DELETE requests and then sending the appropriate query to the GraphQL endpoint.
Here is what the handler for creating new subscriptions looks like:
//push-subscription.js
...
exports.handler = async function(event) {
const method = event.httpMethod
switch (method) {
case "POST":
try {
const eventData = JSON.parse(event.body)
const { endpoint, data } = eventData
const addResponse = await addSubscription(endpoint, data)
console.log("New Subscription added for: ", endpoint)
return addResponse
} catch (error) {
console.error("Error saving subscription ", error)
return {
statusCode: 400,
body: `{"error": "Unable to process"}`,
}
}
...
For sending notifications I created a separate Netlify function push-notification.js
.
There is an additional package web-push
that makes the call to a subscription endpoint (and creates all the necessary encryption using the private key) which then triggers the notification on the user’s device.
This Netlify function runs a GraphQL query to get all subscriptions and sends a web push to each endpoint, the rest of the data required by web-push
is stored in the subscription
field returned from the database.
Service Worker
Even though the service worker creates the notification that the user sees, it actually turned out to be the simplest piece of this puzzle. I admittedly only implemented creating notifications with a customized title
and message
. However, adding support for custom icons, urls (right now, clicking the notification just goes to the homepage of the blog) and adding tags would not be too complicated.
self.addEventListener(“notificationclick”, function(event))
handles a new notification sent from the backend, this listener is constantly running in the background even when the site is closed.
self.registration.showNotification(title, options)
sends a new notification.
The resulting service worker code:
self.addEventListener("push", (event) => {
const notifcationOptions = { title: "Damola's Blog" }
if (event.data) {
notifcationOptions.message = event.data.text()
try {
const notificationData = event.data.json()
notifcationOptions.title = notificationData.title
notifcationOptions.message = notificationData.message
} catch (error) {
console.error("Error processing notification data", error)
}
}
event.waitUntil(
self.registration.showNotification(notifcationOptions.title, {
body: notifcationOptions.message
? notifcationOptions.message
: "Site Update",
})
)
})
self.addEventListener("notificationclick", (event) => {
event.notification.close()
const url = "https://dshomoye.dev"
clients.openWindow(url)
})
And then I updated gatsby-config
to include the custom service worker code, like so:
//gatsby-config.js
...
{
resolve: `gatsby-plugin-offline`,
options: {
appendScript: require.resolve(`${__dirname}/src/notification-sw.js`),
},
},
...
And that’s it!
I send notifications using Postman right now. I considered building out a UI to call the Netlify function I created, but I only need to send a title and a message, it was too much effort for too little gain.
References
Here are some links that will/should hopefully help with getting started and might have been missed in the article:
- Web Push Notifications tutorial.
- Offline plugin for Gatsby - this plugin is not required but adding offline capability is very beneficial, the plugin also automatically sets up the service worker.
- web-push npm package.
- Code is public, you can view the full source here.
Last updated: September 18, 2022