By Samuele Zaza
While we believe that this content benefits our community, we have not yet thoroughly reviewed it. If you have any suggestions for improvements, please let us know by clicking the “report an issue“ button at the bottom of the tutorial.
This tutorial is out of date and no longer maintained.
So you have this incredible new generation app ready for the public, and you are now thinking about making it progressive. You already know that progressive web apps (PWAs) are the current trend as you did your research on Google developers portal and learned everything about it.
Then, here on Scotch, you read The Ultimate Guide to Progressive Web Apps and realized the current stage of integration of PWA technologies with the most popular front-end frameworks and libraries.
Your app is written in React and the official create-react-app boilerplate builds a project as a progressive web app since last May here.
Unfortunately, you did not use any boilerplate, and you are now wondering how to achieve your goal of building a PWA.
Good news, this is the article you were looking for!
In about 10 minutes, I am going to show you how to leverage React and Webpack to fully support PWA.
No need to say it, you should already be familiar with the technologies discussed throughout the tutorial. In particular:
react-dom
, react-router
, etc.).By the way, you can find the sample code on my https://github.com/samuxyz/react-pwa.
--src
----components
------App.js
----pwa
------logo.png
------manifest.json
----index.js
--.babelrc
--package.json
--template.html
--webpack-loaders.js
--webpack-paths.js
--webpack-plugins.js
--webpack.config.js
--yarn.lock
Google Lighthouse is an automated tool that generates a detailed report with a score and suggestions on how to improve your web app. It evaluates several factors such as performance, accessibility, best practices, and progressive web apps features.
We can use it to perform a preliminary evaluation of the current stage of your web app. It’s easy; Lighthouse comes as a browser extension so go ahead and install it from the Chrome Web Store.
At this point, let’s open the terminal, go to react-pwa
folder and run
- yarn start
We are serving the app from webpack-dev-server
in development mode so we may receive a low score in the performance report, but don’t worry; this will be solved in production by running yarn build
.
Now, open the browser and go to http://localhost:8080
to be redirected to the homepage of the app, then click on Generate a Report:
After a few moments of analysis, here is the report from Lighthouse:
Well, nothing unexpected, the performances are bad, 17/100, and the PWA aspects need improvement 27/100.
Luckily, Lighthouse lists several tips to improve the report, and this is going to be our starting point.
Let’s go!
Preliminary App Evaluation
It’s good to recap the list of improvement tips:
Let’s kick off with the easiest entry. We want to show some messages to the users when JavaScript is disabled, point 3 in the list.
Let’s open template.html
and add a noscript
tag:
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="author" content="Samuele Zaza">
<meta name="theme-color" content="#ffffff">
<title>React PWA</title>
</head>
<body>
<div id="app"></div>
</body>
<!-- Prompt a message in the browser if users disabled JS -->
<noscript>Your browser does not support JavaScript!</noscript>
</html>
We achieved our goal in a single line of code.
At this point, let’s generate a new report with Lighthouse to see the new score.
Well, there is indeed an improvement, but we are still far from the desired 100/100, let’s move on.
The other entries can all be solved with a service worker and manifest file.
A service worker is a script your browser runs in the background that PWAs use for offline experience and periodic sync. To run our app in an offline environment, we need to cache its static assets and find a solution to check the network status and updates periodically.
What do we have to do to integrate a service worker in our React app?
Our app is using webpack
to bundle our assets, and we all know it is an amazing tool.
In few lines of code and thanks to loaders and plugins, we can create chunks, hash them, extract CSS, images, fonts, and so on. This, though, may lead to some difficulties as the service worker usually requires the list of static assets, and good practices like hashing the bundles make them hard to be easily tracked.
Luckily, webpack-manifest-plugin
is the solution to our problem as it comes in handy to create a JSON file with the listed assets webpack created at bundle time. Besides, the file is conveniently created in the /public
folder of our app along with the static assets.
Our app does not deal with hash and chunks so the asset-manifest.json
created will look like this:
{
"main.css": "style.css",
"main.css.map": "style.css.map",
"main.js": "bundle.js"
}
Later on, we will need to find a way to let the service worker read the following file but let’s install first webpack-manifest-plugin
:
- yarn add webpack-manifest-plugin -D
Then, add it in webpack.plugin.js
where all the plugins are exported:
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
/* Import webpack-manifest-plugin */
const ManifestPlugin = require('webpack-manifest-plugin');
const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
exports.loaderOptions = new webpack.LoaderOptionsPlugin({
options: {
context: __dirname,
},
});
exports.environmentVariables = new webpack.DefinePlugin({
'process.env': {
'NODE_ENV': JSON.stringify(process.env.NODE_ENV),
},
});
exports.uglifyJs = new webpack.optimize.UglifyJsPlugin({
output: {
comments: false,
},
compress: {
warnings: false,
drop_console: true,
},
});
exports.extractText = (() => {
const config = {
filename: 'style.css',
};
return new ExtractTextPlugin(config);
})();
/* The basic is very easy, just define the file name and
* it's gonna be created in the public folder along with the assets
*/
exports.manifest = new ManifestPlugin({
fileName: 'asset-manifest.json', // Not to confuse with manifest.json
});
Finally, let’s add the plugin in the production configuration webpack.config.js
:
"use strict";
const webpack = require('webpack');
const merge = require('webpack-merge');
const PATHS = require('./webpack-paths');
const loaders = require('./webpack-loaders');
const plugins = require('./webpack-plugins');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const common = {
entry: PATHS.src,
output: {
path: PATHS.public,
filename: 'bundle.js',
},
module: {
rules: [
loaders.babel,
loaders.extractCss,
],
},
resolve: {
alias: {
components: PATHS.components,
},
extensions: ['.js', '.jsx'],
},
plugins: [
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'template.html',
}),
plugins.extractText,
],
};
let config;
switch(process.env.NODE_ENV) {
case 'production':
config = merge(
common,
{ devtool: 'source-map' },
{
plugins: [
plugins.loaderOptions,
plugins.environmentVariables,
plugins.uglifyJs,
plugins.manifest, // Add the manifest plugin
],
},
);
break;
case 'development':
config = merge(
common,
{ devtool: 'eval-source-map' },
loaders.devServer({
host: process.env.host,
port: process.env.port,
}),
);
break;
}
module.exports = config;
Awesome, when deploying to a server and bundle for production the /public
folder will contain our asset-manifest.json
.
It’s now time to create our service worker!
We could create it manually but why don’t we just rely on webpack? In fact, there is another awesome plugin, sw-precache-webpack-plugin
, used by the official create-react-app
too, that can generate a service worker file using sw-precache
and add it to the build directory /public
.
In addition, it works great with our asset-manifest.json
as it can read it to let the service worker be aware of the files to cache.
Let’s install it with yarn:
yarn add sw-precache-webpack-plugin -D
As we did for the previous plugin, let’s add the new one to webpack.plugins.js
:
exports.sw = new SWPrecacheWebpackPlugin({
// By default, a cache-busting query parameter is appended to requests
// used to populate the caches, to ensure the responses are fresh.
// If a URL is already hashed by Webpack, then there is no concern
// about it being stale, and the cache-busting can be skipped.
dontCacheBustUrlsMatching: /\.\w{8}\./,
filename: 'service-worker.js',
logger(message) {
if (message.indexOf('Total precache size is') === 0) {
// This message occurs for every build and is a bit too noisy.
return;
}
console.log(message);
},
minify: true, // minify and uglify the script
navigateFallback: '/index.html',
staticFileGlobsIgnorePatterns: [/\.map$/, /asset-manifest\.json$/],
});
The following option parameters and configuration are directly taken from create=react-app
as it’s a production-ready configuration that works pretty well for our needs.
The service worker created in service-worker.js
is fully aware of the files to precache.
The last step is to add the plugin to the production configuration in webpack.config.js
:
"use strict";
const webpack = require('webpack');
const merge = require('webpack-merge');
const PATHS = require('./webpack-paths');
const loaders = require('./webpack-loaders');
const plugins = require('./webpack-plugins');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const common = {
entry: PATHS.src,
output: {
path: PATHS.public,
filename: 'bundle.js',
},
module: {
rules: [
loaders.babel,
loaders.extractCss,
],
},
resolve: {
alias: {
components: PATHS.components,
},
extensions: ['.js', '.jsx'],
},
plugins: [
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'template.html',
}),
plugins.extractText,
],
};
let config;
switch(process.env.NODE_ENV) {
case 'production':
config = merge(
common,
{ devtool: 'source-map' },
{
plugins: [
plugins.loaderOptions,
plugins.environmentVariables,
plugins.uglifyJs,
plugins.manifest, // Add the manifest plugin
plugins.sw, // Add the sw-precache-webpack-plugin
],
},
);
break;
case 'development':
config = merge(
common,
{ devtool: 'eval-source-map' },
loaders.devServer({
host: process.env.host,
port: process.env.port,
}),
);
break;
}
module.exports = config;
We are almost done. We only need to register the service worker in our app.
First, create a script registerServiceWorker.js
in /src
and copy the code suggested in the create-react-app
:
// In production, we register a service worker to serve assets from local cache.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on the "N+1" visit to a page, since previously
// cached resources are updated in the background.
// To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
// This link also includes instructions on opting out of this behavior.
export default function register () { // Register the service worker
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
window.addEventListener('load', () => {
const swUrl = 'service-worker.js';
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the old content will have been purged and
// the fresh content will have been added to the cache.
// It's the perfect time to display a "New content is
// available; please refresh." message in your web app.
console.log('New content is available; please refresh.');
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
});
}
}
export function unregister () {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then(registration => {
registration.unregister();
});
}
}
Finally, we need to import the script in /src/index.js
and register the service worker by running register:
import React from 'react';
import { render } from 'react-dom';
import App from 'components/App';
/* import the script */
import registerServiceWorker from './registerServiceWorker';
render (
<App />,
document.getElementById('app'),
);
registerServiceWorker(); // Runs register() as default function
The last 2 points in the Lighthouse tips warned us about the missing manifest.json
file.
The web app manifest information about an application (such as name, author, icon, and description) in a JSON text file. The purpose of the manifest is to install web applications to the home screen of a device, providing users with quicker access and a richer experience.
So, let’s create manifest.json
in /pwa
and paste the following code:
{
"short_name": "React PWA",
"name": "My First React PWA",
"icons": [
{
"src": "logo.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "./index.html",
"display": "standalone",
"theme_color": "#ffffff",
"background_color": "#ffffff"
}
Where
short_name
provides a short human-readable name for the application.name
is a human-readable name for the application as it is intended to be displayed to the user.icons
is an array of image objects that can serve as application icons in various contexts, in our case one is enough.start_url
specifies the URL that loads when a user launches the application from a device.display
defines the developer’s preferred display mode for the web application.theme_color
defines the default theme color for an application.background_color
sets the expected background color for the web application.Note: we are expecting to have the icon logo.png
within the same folder of manifest.json
. Both will be copied to /public
when building in production.
If you have any automated tools to deploy your server and client codes you may not be triggered by the idea of manually moving the manifest.json
and icon picture to the /public
folder every new release. Instead, it would be great to programmatically copy the content of /src/pwa
into /public
.
webpack
and its community have the perfect plugin to achieve this, copy-webpack-plugin
, that can easily copy files or the content of a directory to the output bundle folder.
Go ahead and install it with yarn
- yarn add copy-webpack-plugin -D
And edit again webpack.plugins.js
:
const webpack = require('webpack');
const ExtractTextPlugin = require('extract-text-webpack-plugin');
const ManifestPlugin = require('webpack-manifest-plugin');
const SWPrecacheWebpackPlugin = require('sw-precache-webpack-plugin');
/* Import copy-webpack-plugin */
const CopyWebpackPlugin = require('copy-webpack-plugin');
exports.loaderOptions = new webpack.LoaderOptionsPlugin({
options: {
context: __dirname,
},
});
exports.environmentVariables = new webpack.DefinePlugin({
'process.env': {
'NODE_ENV': JSON.stringify(process.env.NODE_ENV),
},
});
exports.uglifyJs = new webpack.optimize.UglifyJsPlugin({
output: {
comments: false,
},
compress: {
warnings: false,
drop_console: true,
},
});
exports.extractText = (() => {
const config = {
filename: 'style.css',
};
return new ExtractTextPlugin(config);
})();
exports.manifest = new ManifestPlugin({
fileName: 'asset-manifest.json',
});
exports.sw = new SWPrecacheWebpackPlugin({
dontCacheBustUrlsMatching: /\.\w{8}\./,
filename: 'service-worker.js',
logger(message) {
if (message.indexOf('Total precache size is') === 0) {
return;
}
console.log(message);
},
minify: true,
navigateFallback: '/index.html',
staticFileGlobsIgnorePatterns: [/\.map$/, /asset-manifest\.json$/],
});
// Export copy-webpack-plugin instance
exports.copy = new CopyWebpackPlugin([
{ from: 'src/pwa' }, // define the path of the files to be copied
]);
We also need to add it to the production configuration in webpack.config.js
:
const webpack = require('webpack');
const merge = require('webpack-merge');
const PATHS = require('./webpack-paths');
const loaders = require('./webpack-loaders');
const plugins = require('./webpack-plugins');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const common = {
entry: PATHS.src,
output: {
path: PATHS.public,
filename: 'bundle.js',
},
module: {
rules: [
loaders.babel,
loaders.extractCss,
],
},
resolve: {
alias: {
components: PATHS.components,
},
extensions: ['.js', '.jsx'],
},
plugins: [
new HtmlWebpackPlugin({
filename: 'index.html',
template: 'template.html',
}),
plugins.extractText,
],
};
let config;
switch (process.env.NODE_ENV) {
case 'production':
config = merge(
common,
{
devtool: 'source-map',
plugins: [
plugins.loaderOptions,
plugins.environmentVariables,
plugins.uglifyJs,
plugins.manifest,
plugins.sw,
/* add webpack-copy-plugin */
plugins.copy,
],
}
);
break;
case 'development':
config = merge(
common,
{ devtool: 'eval-source-map' },
loaders.devServer({
host: process.env.host,
port: process.env.port,
})
);
break;
}
module.exports = config;
Awesome, our webpack
configuration governs all the steps required to make our React app progressive.
There is only one thing missing, we have the manifest.json
and it’s gonna be moved to /public
when deployed to the server but we haven’t included it in our codebase.
Let’s go ahead and import it in template.html
used to create /public/index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="author" content="Samuele Zaza">
<!-- Import manifest.json -->
<link rel="manifest" href="./manifest.json">
<meta name="theme-color" content="#ffffff">
<title>React PWA</title>
</head>
<body>
<div id="app"></div>
</body>
<noscript>Your browser does not support JavaScript!</noscript>
</html>
To serve our app through HTTPS, we can use firebase
. It’s free for our need so, first of all, go to firebase.google.com, log in with your Google account and create a new project in the console.
Once you created your project, we have to install firebase-tools
in the terminal
- yarn global add firebase-tools
And log in through the firebase
login
command.
- firebase login
We have now setup the tools for our specific app, so type firebase init
and reply to the few questions asked in the terminal.
The last step consists of creating the production bundle and PWA assets and deploy them to firebase.
In the terminal just run:
- yarn build && firebase deploy
And the generated public folder will be deployed to firebase!
The last line shows the app URL. Let’s go ahead on open it in the browser and generate a new report from Lighthouse:
Congratulations, we got 100/100!
Now, let’s try to run the app offline and see the PWA in action:
In the development tools click on the application tab, select service workers in the left menu, and check offline. Then, refresh the page and you can still navigate through the views!
Mobile Experience
The real magic happens on the mobile version: Once you open the app in the mobile browser save it to the home screen, and you will see the icon at the bottom of the menu:
Clicking on the React PWA should give users the feeling of being an app. Click on it and the splash screen is going to welcome you.
Finally, try to navigate offline to see the precache in action!
In this short tutorial, we have leveraged webpack
to handle all the steps to build a progressive web app with React.
Starting from the existing code of an app and its webpack
configuration we have added a few plugins to create a service worker able to precache the app assets.
To enhance the user experience and give users a feeling of working with a mobile app we also added a manifest in charge to create the splash screen and icon for the app.
That’s just a first step into the PWA world so stay tuned for more content in the future!
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!
Sign up for Infrastructure as a Newsletter.
Working on improving health and education, reducing inequality, and spurring economic growth? We'd like to help.
Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.