How to Make Your Existing React App Progressive in 10 Minutes

Samuele Zaza
👁️ 3,817 views
💬 comments

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 frontend 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 (article 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.

Prerequisites

No need to say it, you should already be familiar with the technologies discussed throughout the tutorial. In particular:

  • React and its huge family of related packages (react-dom, react-router...).
  • Webpack bundler as we are going to edit its configuration by adding useful plugins to help us build a PWA out of a sample app.

By the way, you can find the sample code on my github.

Folder Structure

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

Preliminary App Evaluation

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:

  1. Does not register a Service Worker.
  2. Does not respond with a 200 when offline.
  3. Does not provide fallback content when JavaScript is not available.
  4. Does not redirect HTTP traffic to HTTPS.
  5. Page load is not fast enough on 3G. (in production our bundle is gonna be way smaller!)
  6. User will not be prompted to Install the Web App.
  7. Is not configured for a custom splash screen.
  8. Address bar does not match brand colors.

Let's kick off with the easiest entry. We want to show some message 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.

Service Worker

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?

  1. It sounds obvious but we gotta create a service worker.
  2. Register it within the 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 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 react-create-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 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 react-create-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 react-create-app migration guide:

// 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

Create the Manifest.json

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

NB: 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 has 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 include 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>

Deploy to Firebase

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.

  1. Which Firebase CLI features do you want to setup for this folder? Press Space to select features, then Enter to confirm your choices. Choose Hosting.
  2. Select a default Firebase project for this directory: Choose the project you have just created.
  3. What do you want to use as your public directory? Leave default to public
  4. Configure as a single-page app (rewrite all urls to /index.html)? Click enter.

The last step consists on 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!

Conclusions

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!

Samuele Zaza

7 posts

I am a full-stack web developer working for Taroko Software as front-end web developer and Filestack Tech Evangelist. When not coding I may be spotted in a gym lifting or planning to conquer the world LOL.