TL;DR: Web development has evolved significantly over the years allowing developers to deploy a website or web application and serve millions of people around the globe within minutes. With just a browser, a user can put in a URL and access a web application. With, Progressive Web Apps, developers can deliver amazing app-like experiences to users using modern web technologies. In Part 1 and Part 2 of this tutorial, we set up our progressive web app, cached the pages and made it work fully offline. This time, we'll add the ability to activate push notifications.
Recap and Introduction to Part 3
In Introduction to Progressive Web Apps (Offline First), we discussed how a typical progressive web application should look like and also introduced the service worker. We also cached the application shell. In Introduction to Progressive Web Apps (Instant Loading), we made the app cache dynamic data and load instantly from locally saved data.
This part of the tutorial will cover:
- Activating Push Notifications in the web app
- Adding a web application manifest to make the web app installable
Push Notifications
The Push API gives web applications the ability to receive push notification messages pushed to them from a server. This works hand in hand with the service worker. This is the process of a typical push notification flow in a web application:
- The web app brings forward a popup asking the user to subscribe to notifications.
- The user subscribes to receive push notifications.
- The service worker's push manager is responsible for handling the user's subscription.
- The users subscription ID is used whenever messages are posted from the server. Every user can actually have a customized experience based on their subscription ID.
- The service worker, with the help of the
listens and is ready to receive any message coming in.push
Implementation
This is a quick summary of the process of how we'll set up push notifications in this web app:
- Give the user an option to click on a button to activate or deactivate push notifications
- If the user activates, subscribe the user to receive push notifications via the service worker's push manager
- Set up an API to handle saving and deleting of users subscription ID. This API will also have endpoints that will be responsible for sending notifications to all the users that have activated push notifications
- Set up GitHub Webhook to automate the sending of notifications immediately a new commit is pushed to the
repo.resources-i-like
Let's Build
Create a new JavaScript file
js/notification.js
in your project. Reference the file in your index.html
like so:<script src="./js/notification.js"></script>
..and add the following code the
notification.js
like so:(function (window) { 'use strict'; //Push notification button var fabPushElement = document.querySelector('.fab__push'); var fabPushImgElement = document.querySelector('.fab__image'); //To check `push notification` is supported or not function isPushSupported() { //To check `push notification` permission is denied by user if (Notification.permission === 'denied') { alert('User has blocked push notification.'); return; } //Check `push notification` is supported or not if (!('PushManager' in window)) { alert('Sorry, Push notification isn\'t supported in your browser.'); return; } //Get `push notification` subscription //If `serviceWorker` is registered and ready navigator.serviceWorker.ready .then(function (registration) { registration.pushManager.getSubscription() .then(function (subscription) { //If already access granted, enable push button status if (subscription) { changePushStatus(true); } else { changePushStatus(false); } }) .catch(function (error) { console.error('Error occurred while enabling push ', error); }); }); } // Ask User if he/she wants to subscribe to push notifications and then // ..subscribe and send push notification function subscribePush() { navigator.serviceWorker.ready.then(function(registration) { if (!registration.pushManager) { alert('Your browser doesn\'t support push notification.'); return false; } //To subscribe `push notification` from push manager registration.pushManager.subscribe({ userVisibleOnly: true //Always show notification when received }) .then(function (subscription) { toast('Subscribed successfully.'); console.info('Push notification subscribed.'); console.log(subscription); //saveSubscriptionID(subscription); changePushStatus(true); }) .catch(function (error) { changePushStatus(false); console.error('Push notification subscription error: ', error); }); }) } // Unsubscribe the user from push notifications function unsubscribePush() { navigator.serviceWorker.ready .then(function(registration) { //Get `push subscription` registration.pushManager.getSubscription() .then(function (subscription) { //If no `push subscription`, then return if(!subscription) { alert('Unable to unregister push notification.'); return; } //Unsubscribe `push notification` subscription.unsubscribe() .then(function () { toast('Unsubscribed successfully.'); console.info('Push notification unsubscribed.'); console.log(subscription); //deleteSubscriptionID(subscription); changePushStatus(false); }) .catch(function (error) { console.error(error); }); }) .catch(function (error) { console.error('Failed to unsubscribe push notification.'); }); }) } //To change status function changePushStatus(status) { fabPushElement.dataset.checked = status; fabPushElement.checked = status; if (status) { fabPushElement.classList.add('active'); fabPushImgElement.src = '../images/push-on.png'; } else { fabPushElement.classList.remove('active'); fabPushImgElement.src = '../images/push-off.png'; } } //Click event for subscribe push fabPushElement.addEventListener('click', function () { var isSubscribed = (fabPushElement.dataset.checked === 'true'); if (isSubscribed) { unsubscribePush(); } else { subscribePush(); } }); isPushSupported(); //Check for push notification support })(window);
The code above is doing many things. Just relax, I'll explain the different parts of the code.
//Push notification button var fabPushElement = document.querySelector('.fab__push'); var fabPushImgElement = document.querySelector('.fab__image');
Push Notification button
This code simply grabs the push notification activation and deactivation button.
function isPushSupported() { //To check `push notification` permission is denied by user if (Notification.permission === 'denied') { alert('User has blocked push notification.'); return; } //Check `push notification` is supported or not if (!('PushManager' in window)) { alert('Sorry, Push notification isn\'t supported in your browser.'); return; } //Get `push notification` subscription //If `serviceWorker` is registered and ready navigator.serviceWorker.ready .then(function (registration) { registration.pushManager.getSubscription() .then(function (subscription) { //If already access granted, enable push button status if (subscription) { changePushStatus(true); } else { changePushStatus(false); } }) .catch(function (error) { console.error('Error occurred while enabling push ', error); }); }); }
This code checks the browser to determine whether push notification is supported. Now, it's paramount that the service worker has to be registered and ready before you can even try to subscribe a user to receive push notifications. So, the code above also checks if the service worker is ready and gets the subscription of the user.
//To change status function changePushStatus(status) { fabPushElement.dataset.checked = status; fabPushElement.checked = status; if (status) { fabPushElement.classList.add('active'); fabPushImgElement.src = '../images/push-on.png'; } else { fabPushElement.classList.remove('active'); fabPushImgElement.src = '../images/push-off.png'; } }
Change push status to red when active/subscribed
Change push status to ash when inactive/unsubscribed
The
changePushStatus
function simply changes the color of the button to indicate wether the user has subscribed or not.// Ask User if he/she wants to subscribe to push notifications and then // ..subscribe and send push notification function subscribePush() { navigator.serviceWorker.ready.then(function(registration) { if (!registration.pushManager) { alert('Your browser doesn\'t support push notification.'); return false; } //To subscribe `push notification` from push manager registration.pushManager.subscribe({ userVisibleOnly: true //Always show notification when received }) .then(function (subscription) { toast('Subscribed successfully.'); console.info('Push notification subscribed.'); console.log(subscription); //saveSubscriptionID(subscription); changePushStatus(true); }) .catch(function (error) { changePushStatus(false); console.error('Push notification subscription error: ', error); }); }) }
This code above is responsible for the pop-up that comes forth asking the user to either allow or block push notifications in the browser. If the user allows push notifications, it shows a toast message indicating the approval, then goes ahead to change the color of the button and save the subscription ID. If the push manager doesn't exist then it alerts the user that it is not supported.
Note: The function that saves subscription ID has been commented out for now.
Ask the user to allow or block notifications
Subscription in the console
// Unsubscribe the user from push notifications function unsubscribePush() { navigator.serviceWorker.ready .then(function(registration) { //Get `push subscription` registration.pushManager.getSubscription() .then(function (subscription) { //If no `push subscription`, then return if(!subscription) { alert('Unable to unregister push notification.'); return; } //Unsubscribe `push notification` subscription.unsubscribe() .then(function () { toast('Unsubscribed successfully.'); console.info('Push notification unsubscribed.'); //deleteSubscriptionID(subscription); changePushStatus(false); }) .catch(function (error) { console.error(error); }); }) .catch(function (error) { console.error('Failed to unsubscribe push notification.'); }); }) }
This code is responsible for unsubscribing from push notification. A toast message indicates the unsubscription, then goes ahead to change the color of the button and delete the subscription ID.
Note: The function that deletes subscription ID has been commented out for now.
Unsubscription in the console
//Click event for subscribe push fabPushElement.addEventListener('click', function () { var isSubscribed = (fabPushElement.dataset.checked === 'true'); if (isSubscribed) { unsubscribePush(); } else { subscribePush(); } });
This code simply adds a click event to the button to toggle between subscribing and unsubscribing a user.
Handle Subscription IDs
We have been able to see the push subscription endpoints. Now, we need to be able to save the subscription IDs of each user, we also need to be able to delete these subscription IDs when a user unsubscribes from push notifications.
Add this code to your
notification.js
like so:function saveSubscriptionID(subscription) { var subscription_id = subscription.endpoint.split('gcm/send/')[1]; console.log("Subscription ID", subscription_id); fetch('http://localhost:3333/api/users', { method: 'post', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id : subscription_id }) }); } function deleteSubscriptionID(subscription) { var subscription_id = subscription.endpoint.split('gcm/send/')[1]; fetch('http://localhost:3333/api/user/' + subscription_id, { method: 'delete', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }); }
Save and Delete Subscription ID
In the code above, we are extracting the subscription ID from the subscription endpoint and posting it to an API service. The
saveSubscriptionID
creates a new user and saves a subscription ID. The deleteSubscriptionID
deletes a user with its subscription ID.This looks weird. Why post to a remote service? It's simple. We need to have a database of all user subscription IDs so that we can send notifications to everyone at once.
API Service
The API service handling the saving and deletion of subscription IDs will also handle the actual sending of notifications. This is a break down of the API. It will have 3 api routes/endpoints:
to create new users and store their subscription IDsPOST /api/users
to delete and unsubscribe usersDELETE /api/user/:user_id
to send notifications to all subscribed usersPOST /api/notify
Lucky enough, I have coded the API service. Make sure you have
node
and mongodb
installed. Clone it and run from the terminal with node server.js
like so:PWA API Server running locally
Make sure you create a
.env
file like so:.env file for pwa-api
Note: You can through this good tutorial to see how the API service should be set up. I simply implemented a node.js version of the API service in this tutorial.
We are using Firebase Cloud Messaging as our messaging service. So, go ahead and set up a new project with firebase. Once you have done that, grab the Server Key from the dashboard, Project settings > Cloud Messaging like so:
Cloud Messaging on Firebase Dashboard
The Server Key should be the value of
FCM_API_KEY
in the .env
file of the API service.The Server Key is needed when posting to Firebase Cloud Messaging via our API Server. Check out the notification controller in our API codebase:
server/controllers/notification.server.controller.js
.... notifyUsers: function(req, res){ var sender = new gcm.Sender(secrets.fcm); // Prepare a message to be sent var message = new gcm.Message({ notification: { title: "New commit on Github Repo: RIL", icon: "ic_launcher", body: "Click to see the latest commit'" } }); User.find({}, function(err, users) { // user subscription ids to deliver message to var user_ids = _.map(users, 'user_id'); console.log("User Ids", user_ids); // Actually send the message sender.send(message, { registrationTokens: user_ids }, function (err, response) { if (err) { console.error(err); } else { return res.json(response); } }); }); }, .....
Now, go back to
notification.js
and uncomment the saveSubscriptionID
and deleteSubscriptionID
functions we commented earlier. Your notification.js
should look like this now:(function (window) { 'use strict'; //Push notification button var fabPushElement = document.querySelector('.fab__push'); var fabPushImgElement = document.querySelector('.fab__image'); //To check `push notification` is supported or not function isPushSupported() { //To check `push notification` permission is denied by user if (Notification.permission === 'denied') { alert('User has blocked push notification.'); return; } //Check `push notification` is supported or not if (!('PushManager' in window)) { alert('Sorry, Push notification isn\'t supported in your browser.'); return; } //Get `push notification` subscription //If `serviceWorker` is registered and ready navigator.serviceWorker.ready .then(function (registration) { registration.pushManager.getSubscription() .then(function (subscription) { //If already access granted, enable push button status if (subscription) { changePushStatus(true); } else { changePushStatus(false); } }) .catch(function (error) { console.error('Error occurred while enabling push ', error); }); }); } // Ask User if he/she wants to subscribe to push notifications and then // ..subscribe and send push notification function subscribePush() { navigator.serviceWorker.ready.then(function(registration) { if (!registration.pushManager) { alert('Your browser doesn\'t support push notification.'); return false; } //To subscribe `push notification` from push manager registration.pushManager.subscribe({ userVisibleOnly: true //Always show notification when received }) .then(function (subscription) { toast('Subscribed successfully.'); console.info('Push notification subscribed.'); console.log(subscription); saveSubscriptionID(subscription); changePushStatus(true); }) .catch(function (error) { changePushStatus(false); console.error('Push notification subscription error: ', error); }); }) } // Unsubscribe the user from push notifications function unsubscribePush() { navigator.serviceWorker.ready .then(function(registration) { //Get `push subscription` registration.pushManager.getSubscription() .then(function (subscription) { //If no `push subscription`, then return if(!subscription) { alert('Unable to unregister push notification.'); return; } //Unsubscribe `push notification` subscription.unsubscribe() .then(function () { toast('Unsubscribed successfully.'); console.info('Push notification unsubscribed.'); console.log(subscription); deleteSubscriptionID(subscription); changePushStatus(false); }) .catch(function (error) { console.error(error); }); }) .catch(function (error) { console.error('Failed to unsubscribe push notification.'); }); }) } //To change status function changePushStatus(status) { fabPushElement.dataset.checked = status; fabPushElement.checked = status; if (status) { fabPushElement.classList.add('active'); fabPushImgElement.src = '../images/push-on.png'; } else { fabPushElement.classList.remove('active'); fabPushImgElement.src = '../images/push-off.png'; } } //Click event for subscribe push fabPushElement.addEventListener('click', function () { var isSubscribed = (fabPushElement.dataset.checked === 'true'); if (isSubscribed) { unsubscribePush(); } else { subscribePush(); } }); function saveSubscriptionID(subscription) { var subscription_id = subscription.endpoint.split('gcm/send/')[1]; console.log("Subscription ID", subscription_id); fetch('http://localhost:3333/api/users', { method: 'post', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({ user_id : subscription_id }) }); } function deleteSubscriptionID(subscription) { var subscription_id = subscription.endpoint.split('gcm/send/')[1]; fetch('http://localhost:3333/api/user/' + subscription_id, { method: 'delete', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' } }); } isPushSupported(); //Check for push notification support })(window);
Let's try to activate push notification and see if new users are created and stored in the database of our API service. Reload your app and push the activate button.
Oops, there is an error in our console.
Don't fret! The reason why we are encountering this issue is because we don't have a
manifest.json
file in our web app yet.Now, the interesting thing here is this: Creating a
manifest.json
file will solve this challenge and add another feature to our app. With a manifest.json
file, we'll be able to add our app to a users device homescreen and make the app installable. Viola!!!Go ahead and create a
manifest.json
file in the root directory like so:{ "name": "PWA - Commits", "short_name": "PWA", "description": "Progressive Web Apps for Resources I like", "start_url": "./index.html?utm=homescreen", "display": "standalone", "orientation": "portrait", "background_color": "#f5f5f5", "theme_color": "#f5f5f5", "icons": [ { "src": "./images/192x192.png", "type": "image/png", "sizes": "192x192" }, { "src": "./images/168x168.png", "type": "image/png", "sizes": "168x168" }, { "src": "./images/144x144.png", "type": "image/png", "sizes": "144x144" }, { "src": "./images/96x96.png", "type": "image/png", "sizes": "96x96" }, { "src": "./images/72x72.png", "type": "image/png", "sizes": "72x72" }, { "src": "./images/48x48.png", "type": "image/png", "sizes": "48x48" } ], "author": { "name": "Prosper Otemuyiwa", "website": "https://twitter.com/unicodeveloper", "github": "https://github.com/unicodeveloper", "source-repo": "https://github.com/unicodeveloper/pwa-commits" }, "gcm_sender_id": "571712848651" }
Let's quickly highlight what these keys represent in our web app manifest file.
- name: represents the name of the app as it is usually displayed to the user.
- short_name: represents a short version of the name of the web application.
- description: provides a general description of the web application.
- start_url: is the url that loads when the user launches the web application.
- display: defines the default display mode for the web application. The different modes are
,fullscreen
,standalone
.minimal-ui
- orientation: provides the default orientation for the web application. It could be
orportrait
.landscape
- background_color: represents the background color of the web app.
- theme_color: represents the default theme color of the app. It colors the status bar on Android.
- icons: represents the applications' icon set for the homescreen, splash screen and task switcher.
- author: is a custom key that represents the author of the app
gcmsenderid: represents the
from Firebase Cloud messaging that is used to identify the application. Replace thesender_id
value here with that from your dashboardsender_id
In your
index.html
and latest.html
, reference the manifest.json
file like so:<link rel="manifest" href="./manifest.json">
Now, clear your cache, reload your application and click the notification button.
Subscription ID in my console
Yaaay!!, it works
Subscription ID in my database.
Yaay!!, it got posted
You can see that the subscription IDs are thesame, meaning it got posted and saved in the API service database.
Note: RoboMongo is the IDE I use to manage my mongodb database.
You can try to unsubscribe and see how it deletes the user from the API service database.
Sending and Receiving Notifications
In our API service, we have a
/api/notify
route that we can make a POST request to, and our notification will be fired via the Firebase Cloud Messaging service. Now, that's not enough. We also need a way to listen and accept this notification in the browser.Service Worker to the rescue again! Within the service worker, we can listen to the
push
event like so:sw.js
self.addEventListener('push', function(event) { console.info('Event: Push'); var title = 'New commit on Github Repo: RIL'; var body = { 'body': 'Click to see the latest commit', 'tag': 'pwa', 'icon': './images/48x48.png' }; event.waitUntil( self.registration.showNotification(title, body) ); });
Add that piece of code to the
sw.js
. Clear your cache & reload your app. Now use Postman to send a post request to http://localhost:3333/api/notify
like so:Making the post request with Postman
When a notification is fired, our browser will welcome the notification like so:
Notification received in browser
After receiving the notification, we can decide what to do when a user clicks on the notification. Add this piece of code to your service worker,
sw.js
like so:self.addEventListener('notificationclick', function(event) { var url = './latest.html'; event.notification.close(); //Close the notification // Open the app and navigate to latest.html after clicking the notification event.waitUntil( clients.openWindow(url) ); });
Here, the code above listens to the event that is fired when a user clicks on the notification.
event.notification.close()
, closes the notification once clicked. Then, a new window or tab will be opened re-directing to localhost:8080/latest.html.
Note:
event.waitUntil()
is been called to ensure the browser doesn't terminate our service worker before our new window has been displayed.Automate Notification Sending Process
We have been manually making a post request via Postman. Practically, we want the user to get a notification once a commit has been made to the GitHub repository,
https://github.com/unicodeveloper/resources-i-like/
. So, how do we automate this process?Ever heard of Webhooks? Yes, GitHub Webhooks to the rescue!
Note: Use the repository url of your choice, because you will have to make commits and see that this works as you go through this tutorial.
Head over to the repository of your choice. In my case it is
https://github.com/unicodeveloper/resources-i-like/
. Go to Settings > Webhooks like so:Click on
Add webhook
buttonNow, it's time to add a hook. The hook will be our
notify
API endpoint. When you make a commit on GitHub, a push
event is fired. With this webhook, a POST
request will be sent to /api/notify
API endpoint whenever a commit is made on this particular repository. Sweet!!!Look at the diagram above. Hold on. Wait a minute! There is a strange URL in the Payload URL,
https://ea71f5aa.ngrok.io/api/notify
. Where is https://ea71f5aa.ngrok.io
coming from? How did we get that?Set up Ngrok
It's simple. We can't use a localhost URL. GitHub needs a URL that exists on the internet so I took advantage of a tool called ngrok. With Ngrok, you can expose a local server to the internet.
Install Ngrok. From your terminal, use ngrok to ping the port of the API server like so:
./ngrok http 3333
Ngrok pinging the local API server
So use whatever url it outputs on the terminal from ngrok in the webhook.
Note: Ngrok outputs both
http
and https
URLs, so feel free to use any of them. They still map to your local server.Now, once you have added the webhook, GitHub immediately does a test
POST
ping to the hook to determine if it is all properly set up.Green mark to indicate that the hook URL is valid
Make A Commit
We have set everything up. Now go ahead and make a commit. Once you do that, a push notification will be sent and your browser will receive it like so:
Push Commit, Receive Push Notification
Yes!!!! the process has been totally automated.
Host PWA
One of the requirements of a PWA is to have its content served via HTTPS. Firebase hosting is a very good option for deploying our app to a server that supports HTTPS.
Our app is now live. I also hosted the API on heroku.
For the app to work fully, I changed the URL in the
notification.js
file to the live API URL on heroku. I also changed the Webhook URL to the live API URL.Add App To Homescreen
Open up your browser on your device, especially Chrome and add it like so:
Click on the Ellipsis icon by the right
Add to Homescreen
App now on Homescreen
The PWA and API code is on GitHub.
Aside: Auth0 Authentication with JavaScript
At Auth0, we make heavy use of full-stack JavaScript to help our customers to manage user identities, including password resets, creating, provisioning, blocking, and deleting users. Therefore, it must come as no surprise that using our identity management platform on JavaScript web apps is a piece of cake.
Auth0 offers a free tier to get started with modern authentication. Check it out, or sign up for a free Auth0 account here!
Then, go to the Applications section of the Auth0 Dashboard and click on "Create Application". On the dialog shown, set the name of your application and select Single Page Web Applications as the application type:
After the application has been created, click on "Settings" and take note of the domain and client id assigned to your application. In addition, set the Allowed Callback URLs and Allowed Logout URLs fields to the URL of the page that will handle login and logout responses from Auth0. In the current example, the URL of the page that will contain the code you are going to write (e.g.
http://localhost:8080
).Now, in your JavaScript project, install the
library like so:auth0-spa-js
npm install @auth0/auth0-spa-js
Then, implement the following in your JavaScript app:
import createAuth0Client from '@auth0/auth0-spa-js'; let auth0Client; async function createClient() { return await createAuth0Client({ domain: 'YOUR_DOMAIN', client_id: 'YOUR_CLIENT_ID', }); } async function login() { await auth0Client.loginWithRedirect(); } function logout() { auth0Client.logout(); } async function handleRedirectCallback() { const isAuthenticated = await auth0Client.isAuthenticated(); if (!isAuthenticated) { const query = window.location.search; if (query.includes('code=') && query.includes('state=')) { await auth0Client.handleRedirectCallback(); window.history.replaceState({}, document.title, '/'); } } await updateUI(); } async function updateUI() { const isAuthenticated = await auth0Client.isAuthenticated(); const btnLogin = document.getElementById('btn-login'); const btnLogout = document.getElementById('btn-logout'); btnLogin.addEventListener('click', login); btnLogout.addEventListener('click', logout); btnLogin.style.display = isAuthenticated ? 'none' : 'block'; btnLogout.style.display = isAuthenticated ? 'block' : 'none'; if (isAuthenticated) { const username = document.getElementById('username'); const user = await auth0Client.getUser(); username.innerText = user.name; } } window.addEventListener('load', async () => { auth0Client = await createClient(); await handleRedirectCallback(); });
Replace the
andYOUR_DOMAIN
placeholders with the actual values for the domain and client id you found in your Auth0 Dashboard.YOUR_CLIENT_ID
Then, create your UI with the following markup:
<p>Welcome <span id="username"></span></p> <button type="submit" id="btn-login">Sign In</button> <button type="submit" id="btn-logout" style="display:none;">Sign Out</button>
Your application is ready to authenticate with Auth0!
Check out the Auth0 SPA SDK documentation to learn more about authentication and authorization with JavaScript and Auth0.
In the case of an offline-first app, authenticating the user against a remote database won't be possible when network connectivity is lost. However, with service workers, you have full control over which pages and scripts are loaded when the user is offline. This means you can configure your
offline.html
file to display a useful message stating the user needs to regain connectivity to login again instead of displaying the Lock login screen.Conclusion
We have been able to successfully make our app work offline, load instantly, receive push notifications, and also installable.
Progressive Web Apps have a checklist. I highlighted the requirements in Part 1 . However, there is a tool, Lighthouse for auditing an app for progressive web app features. It is available as a chrome extension and also a CLI.
I recommend that you use this tool frequently when developing a progressive web app. This tutorial wouldn't have been possible without Ire's series on PWA, Timi's server side push notification tutorial, Gokulakrishnan's PWA demo app and the folks at Google that work and blog daily about progressive web apps. Thanks a bunch!
Hopefully you're now ready to dive fully into make your web applications progressive!
About the author
Prosper Otemuyiwa
Former Auth0 Employee