Building a Progressive Web App with Next.js - Part I

How to create a server-side-rendered Progressive Web App with offline support

This is a two-part article and this is the first part where we will create a simple web app using Next.js. The second part is live here where we integrate Service Workers, purge unused CSS, setup server-side caching, etc.

Introduction to PWAs

Progressive Web Apps are the future of web applications. They are really fast, reliable and engaging. Simply put, a PWA is a website or application that feels and behaves like a native app. Many believe they will eventually overshadow native apps. For now, let's focus on what capabilities a web application must have to be considered a Progressive Web app. Lighthouse (now available out of the box under the audit tab in Chrome DevTools) is a developer tool that evaluates an app against a checklist of features that it must have to be considered a PWA. Some of the baseline requirements are -

  1. The site must be served over HTTPS

  2. Pages must be responsive on tablets & mobile devices

  3. All app URLs must load while offline

  4. Metadata must be provided for "Add to Home screen" option using Web App Manifest

  5. Initial loading must be fast even on a slower network like 3G

  6. The site must work cross-browser

  7. Page transitions must feel native-like i.e. either the transition should be near-instant or a loading indicator, placeholder or skeleton screen must be presented.

  8. Each traversable page must have a unique shareable URL

This tutorial we will be creating a basic app with most of these capabilities. Creating a great PWA is no ordinary task, but in this article, we will attempt to establish a good starting point and explore some libraries and packages that will help you get started on the right path. We will be using React, Next, Express, Webpack and Service Worker-related libraries. So, prior experience with at least some of these technologies will be helpful. The source code is available on Github.

For a refresher on some of them, you can take a look at some of the articles I've previously written - Introduction to React, Introduction to Webpack 4, React Server-Side Rendering from Scratch

So, now that we know what features are must for a PWA, let's talk about how we plan to implement them -

  1. Serving a site over HTTPS has never been easier. Services like letsencrypt can help with it. You can also use services like now, netlify, etc. for easy deployment that take care of this for you.

  2. There are numerous CSS libraries that make creating responsive sites a breeze. Native CSS tools like Flexbox and Grid further ease our case here. In this article, we will be using Spectre.css.

  3. To create an offline experience, we will leverage Service Workers. We will use the Workbox library by Google to precache the static assets we will need. We will also enable runtime caching for dynamic resources like API requests and images from external sources by utilizing smart caching strategies that are baked into the Workbox library.

  4. For our users to be able to add our PWA to their home screen, we need to provide a Web Manifest file. We will use the webpack-pwa-manifest webpack plugin to generate a manifest.json file that we will serve with our application.

  5. For fast initial load, we will server-side render our React app using Next.js. Next.js has built-in route-based code-splitting which will further help in reducing the size of our initial JS bundle that needs to be fetched. We will also use a minimal component caching (on Least Recently Used basis) system to further speed up our initial server response time. We will also effectively reduce our CSS payload by purging unused selectors using purifyCSS.

  6. We ensure good cross-browser support by utilizing postcss plugins like autoprefixer which parses and vendor prefixes our CSS code based on the browsers we want to support.

  7. Since this is a Single-Page application, we have a built-in advantage when it comes to page transition. But, Next.js code-splits the app based on routes, hence it can take some time to load new routes especially if they are heavy. To mitigate this issue, we start to prefetch a particular movie details routes when the user hovers over the movie's link (just a simple use-case). We also display a loading indicator on top of the page for visual feedback.

  8. The index and the movie details page will all have unique URLs that can be shared thanks to how Next.js handles routing.

Getting Started

Now, that we know what features we will implement and what approach we will take to implement them. Let's get started by setting up our project using create-next-app - a scaffolding tool similar to create-react-app but for Next.js.

$ npx create-next-app next-pwa

Run this command to create a new Next.js project directory with the necessary dependencies which include next, react and react-dom. First, we will go through some of the basics of Next.js by creating a simple app and then setup advanced configuration and build setup in the second part of the article.

This app uses the TMDB public REST API for movies data. Our app will have two main routes -

1. Index / Home Page - This page renders a list of the upcoming movies. For each of the movies, we render a poster and movie title.

2. Movie Details Page - Each of the movies link to their respective details page. This page displays other details about the movie like plot overview, cast, IMDB rating, genres, etc.

In the next-pwa project directory, we will start by deleting the boilerplate content in the pages and components directory. Anyone familiar with React will know the components directory is usually used to store the React components that we will write for the app (by convention). One important thing to note here is the pages directory. It is a part of the Next.js API. Next.js smartly uses the filesystem as its routing API. The files inside this directory are used as routes in our Next.js app based on the filenames. For now, you can be sure that there will be at least two files inside the pages directory - one for the Index page (index.js) and another one for the Movie Details Page (movie.js).

The static directory at the project root location will be used to serve our static assets and are usually not managed by the default Next.js build setup. In our case, it will hold our Workbox related Service Worker files and favicon.ico.

Setting up data fetching logic

We will start with the simplest of the tasks i.e. creating the data fetching utilities that our app will need. We will create a utils directory at the project root and create a file named apiCalls.js to write the logic for our data fetching needs. Also, we will create another file named config.js in the same directory which will contain our TMDB API key. You can add your TMDB API key by creating this file or use a .env file (for simplicity, we are using just a "gitignored" js file to manage our secrets here).

$ npm i isomorphic-unfetch

We will be using a package named isomorphic-unfetch so that we can use the same fetch API on both the client as well as the server.

/* ./utilities/apiCalls.js */

import 'isomorphic-unfetch';

import { API_KEY } from './config';

const BASE_URI = 'https://api.themoviedb.org/3/movie';
const IMAGE_BASE_URI = 'https://image.tmdb.org/t/p';

const fetchWithErrorHandling = async url => {
  try {
    return await (await fetch(url)).json();
  } catch (err) {
    return { error: true };
  }
};

export const getMovieDetails = async id =>
  fetchWithErrorHandling(
    `${BASE_URI}/${id}?api_key=${API_KEY}&language=en-US&append_to_response=credits`
  );

export const getUpcomingMovies = async () =>
  fetchWithErrorHandling(
    `${BASE_URI}/upcoming?api_key=${API_KEY}&language=en-US&page=1`
  );

export const getImageSrc = (path, size) =>
  `${IMAGE_BASE_URI}/${size ? `w${size}` : 'original'}${path}`;

Here, we export out two API related functions - getMovieDetails to fetch the details of a movie based on it id value and getUpcomingMovies to fetch a list of upcoming movies. We use an internal function fetchWithErrorHandling to wrap the fetch API related code with some basic error handling mechanism. We also export out a helper function named getImageSrc that returns a valid image source address when passed valid file path and size parameters that we receive from the movie details API response.

Page Components

Next, we will move on to create our route pages i.e. index.js and movie.js under the pages directory. Both, the pages are simple functional components (AKA stateless components). They take in some props that we destructure using ES6 object destructuring and then use them to render some UI. We get the props by using the API related functions we imported from utils/apiCalls.js earlier. getInitialProps is a static method available as a Next.js specific API for page components only. It is called in order to populate initial props to the page as the name suggests. Hence, it is the best place for fetching data from APIs that are needed to render a page on the server as well as the client.

In index.js, we create a Home component which is responsible for what needs to be rendered on the home or index page. We use the getUpcomingMovies function from apiCalls.js inside the getInitialProps static method which either provides a movies array or an error object. Based on the props, we conditionally render an Oops component or a list of Movie Components. We will discuss both of these components in details shortly.

index.js

import Head from 'next/head';

import Movie from '../components/Movie';
import Oops from '../components/Oops';

import { getUpcomingMovies } from '../utils/apiCalls';

const Home = ({ movies, error }) => (
  <div className="home">
    <Head>
      <title>Index | Movies PWA</title>
    </Head>
    {error ? <Oops /> : movies.map(props => <Movie {...props} key={props.id} />)}
  </div>
);

Home.getInitialProps = async () => {
  const res = await getUpcomingMovies();
  if (res.error) return res;

  const movies = res.results.map(({ title, id, poster_path, overview }) => ({
    title,
    poster_path,
    overview,
    id,
  }));

  return { movies };
};

export default Home;

movie.js

import Link from 'next/link';
import Head from 'next/head';

import Oops from '../components/Oops';
import Image from '../components/Image';

import { getMovieDetails, getImageSrc } from '../utils/apiCalls';

const MoviePage = ({ title, poster_path, rating, overview, genres, cast, error }) =>
  error ? (
    <div className="movie">
      <Oops />
    </div>
  ) : (
    <div className="movie">
      <Head>
        <title>{title} | Movies PWA</title>
      </Head>
      <div className="card">
        <div className="card-header">
          <div className="card-title h3 text-primary">{title}</div>
          <div className="card-subtitle text-gray">Rating - {rating}</div>
          <div className="chips">
            {genres.map(({ id, name }) => (
              <div key={id} className="chip">
                {name}
              </div>
            ))}
          </div>
        </div>
        <div className="card-image">
          <Image src={getImageSrc(poster_path, 500)} alt={`Poster for ${title}`} className="img-responsive" />
        </div>
        <div className="card-body">
          <div className="card-title h4">Overview</div>
          <div className="card-subtitle">{overview}</div>
          <div className="card-title h4">Cast</div>
          <div className="cast">
            {cast.map(
              ({ credit_id, profile_path, name }) =>
                profile_path ? (
                  <figure key={credit_id} className="avatar avatar-xl tooltip" data-tooltip={name}>
                    <Image src={getImageSrc(profile_path, 92)} alt={name} />
                  </figure>
                ) : null
            )}
          </div>
        </div>
        <div className="card-footer btn-group btn-group-block">
          <Link href="/">
            <button className="btn btn-primary">Back to Home</button>
          </Link>
        </div>
      </div>
    </div>
  );

MoviePage.getInitialProps = async ({ query: { id } }) => {
  const res = await getMovieDetails(id);
  if (res.error || !res.original_title) return res;

  const {
    poster_path,
    overview,
    genres,
    credits: { cast },
    vote_average,
    original_title,
  } = res;

  return {
    title: original_title,
    poster_path,
    rating: vote_average,
    overview,
    genres,
    cast: cast.filter((c, i) => i < 5),
  };
};

export default MoviePage;

In the MoviePage component (in movie.js), we use the Link component imported from the next/link package. This is very similar to other React routing solutions like React Router. Link is like <a/> tag. It is used to define an element on the page that links to another page or URL. We use it to create a link to the home page. We use the prefetch attribute to prefetch the code needed for Home as soon as we can to provide a better route transition experience as we have discussed before. Here, the use of prefetch is following a declarative API.

One thing to note here is the Head component imported from next/head package. We use that Head component to render a <title> tag as its child. This helps us change the title of the page depending on the current route that is being rendered. Another thing to note is the Oops component imported into both our pages from Oops.js under the components directory. It is just a simple React component that displays a message when a route cannot be loaded to convey the message that the user is probably offline and the data needed to render the page is not available in the cache as well.

The Image component is imported and used in both Movie and MoviePage (the route component). It uses a package named react-progressive-image to achieve better image loading workflow. We use the ProgressiveImage component provided by react-progressive-image to overcome the issue of render blocking images. Instead of waiting for network-resource heavy images to load, we render a placeholder "grey box" image using an inline data URL while we load the actual image. Once the image is done loading it replaces the src to render the actual image which is now loaded and ready. This further boosts our initial load times.

The Image component is simply a wrapper using the ProgressiveImage component under the hood with some added niceties like a <noscript> fallback and a CSS driven fade effect. If you are familiar with the common render props or render callback pattern in React, the code is very easy to follow. We provide the src props with the actual image address and placeholder props with the placeholder image address on the ProgressiveImage component. Then, we use the render callback that gets passed the currentSrc and loading values (depending on whether the original image is ready to be rendered or not) to render our <img> tag.

$ npm i react-progressive-image

Image.js

import React, { Fragment } from 'react';
import ProgressiveImage from 'react-progressive-image';

const Image = ({ src, alt, className }) => (
  <ProgressiveImage
    src={src}
    placeholder="data:image/gif;base64,R0lGODlhAQABAIAAAHd3dwAAACH5BAAAAAAALAAAAAABAAEAAAICRAEAOw=="
  >
    {(currentSrc, loading) => (
      <Fragment>
        <img src={currentSrc} alt={alt} className={loading ? `${className} loading-img` : className} />
        <noscript>
          <img src={src} alt={alt} className={className} />
        </noscript>
      </Fragment>
    )}
  </ProgressiveImage>
);

export default Image;

Movie.js

import React, { Component } from 'react';
import Router from 'next/router';
import Link from 'next/link';

import Image from './Image';

import { getImageSrc } from '../utils/apiCalls';

class Movie extends Component {
  prefetchMoviePage = () => {
    Router.prefetch(`/movie?id=${this.props.id}`);
  };

  render() {
    const { id, poster_path, title, overview } = this.props;
    return (
      <Link as={`/movie/${id}`} href={`/movie?id=${id}`}>
        <div className="card" id={`movie-${id}`} onMouseEnter={this.prefetchMoviePage}>
          <div className="card-image">
            <Image src={getImageSrc(poster_path, 342)} alt={`Poster for ${title}`} className="img-responsive" />
          </div>
          <div className="card-header">
            <div className="card-title h5 text-primary text-ellipsis">{title}</div>
          </div>
          <div className="card-body text-ellipsis">{overview}</div>
        </div>
      </Link>
    );
  }
}

export default Movie;

Oops.js

import React from 'react';

const Oops = () => (
  <div className="card">
    <div className="card-header">
      <div className="card-title h3 text-gray">
        Oops! Looks like you're offline and this page is not cached either. Try again when you're online.
      </div>
    </div>
  </div>
);

export default Oops;

A Movie component (from components directory not the movie.js under pages directory) is also imported into the index page. We use that component to render a card-based list of upcoming movies. We map over the array of movies and render a Movie component for each. We get the needed props passed to it from Home. The interesting thing is the Link and Router components that we get from next/link and next/router packages respectively. We link the component to the details pages for that particular movie using the unique id props. The href prop is used to refer to the actual URL link of the page that will be used to recognise which page to render. Hence, it uses query parameters for routing needs like unique identification of each details page. But, query parameter based URLs are messy and confusing at times so Next.js provides the as props to prettify or clean the URL which can be used to refer to the same URL instead. It is also the address that will be displayed in the address bar of the browser when the page is rendered. The query parameter driven URL will be used solely for internal purposes.

We also use the imperative Route prefetching API for prefetching any route using the static prefetch method available on the Route class exported from next/route package when a user hovers over a Movie component.

Creating Layout and Nav Components

Now, that we have set up the routes that we will need. Let's get started with the parts of the app that will need to persist across routes. The parts crucial to the visual structure of the application like header or navigation based components.

Header.js

import React, { Component } from 'react';
import Router from 'next/router';
import Head from 'next/head';

import Nav from './Nav';

class Header extends Component {
  state = { loading: false };

  componentDidMount() {
    Router.onRouteChangeStart = () => {
      this.setState({ loading: true });
    };
    Router.onRouteChangeComplete = () => {
      this.setState({ loading: false });
    };
    Router.onRouteChangeError = () => {
      this.setState({ loading: false });
    };
  }

  render() {
    return (
      <div className="header">
        <Head>
          <meta charSet="UTF-8" />
          <meta name="description" content="An example PWA" />
          <meta name="viewport" content="width=device-width, initial-scale=1" />
          <meta name="theme-color" content="#302ecd" />
          <title>Movies PWA</title>
          <link rel="manifest" href="/_next/static/manifest.json" />
          <link rel="icon" href="/static/favicon.ico" />
          <link rel="stylesheet" href="/_next/static/style.css" />
        </Head>
        <Loader loading={this.state.loading} />
        <Nav />
      </div>
    );
  }
}

const Loader = ({ loading }) => <div className={loading ? 'loading-show' : ''} id="loader-bar" />;

export default Header;

Nav.js

import Link from 'next/link';

import '../spectre.css';
import '../style.css';

const Nav = () => (
  <header className="navbar">
    <section className="navbar-section">
      <Link prefetch href="/">
        <button className="btn btn-link text-bold">Home</button>
      </Link>
    </section>
  </header>
);

export default Nav;

We create a simple Nav component that does nothing unique or special. It works similarly to how the link to Home worked on the movie details page. The only thing to note is we import two CSS files spectre.css (the spectre css library) and style.css (custom style rules for the app) here. We will extend the configuration setup using next.config.js in the next part of the article so that the build setup can handle CSS properly.

Lastly, we create a Header component that renders the Nav component and also renders some <head> children like meta, title, etc. Again, we use Head from next/head for doing that. It may look similar to what packages like React Helmet does. Anything we put as children of the Head component is rendered under <head> in the DOM. It also collects its other children tags from components down the tree which is why we used it to render route appropriate <title> tags in our page components earlier.

We use the Router package again to implement a loading indicator for our route transitions. In our componentDidMount lifecycle method of the Header component, we utilize hooks for changes in route provided by the Router package to update the value of this.state.loading which in turn hides or shows a loading indicator i.e. the Loader component.

_app.js

import React from 'react';
import App, { Container } from 'next/app';

import Header from '../components/Header';
import OfflineSupport from '../components/OfflineSupport';

class CustomApp extends App {
  render() {
    const { Component, pageProps } = this.props;
    return (
      <Container>
        <Header />
        <OfflineSupport />
        <Component {...pageProps} />
      </Container>
    );
  }
}

export default CustomApp;

_document.js

import Document, { Head, Main, NextScript } from 'next/document';

export default class CustomDocument extends Document {
  render() {
    return (
      <html lang="en">
        <Head />
        <body>
          <Main />
          <NextScript />
        </body>
      </html>
    );
  }
}

We also use a custom App component to render our layout related code i.e. the Header component. Next.js uses this App component to initialize routes and this component can be extended for implementing behaviours like persistent state, layout between pages, custom error handling, etc. To create the custom App component, we need to create _app.js under the pages directory. We use the App and Container component from next/app package to extend its behaviour. The props passed to the App component include Component which is the route specific component to render and pageProps that are the props needed by that route specific component. We use the Container component to render multiple children components which include the Header component we put together earlier and an OfflineSupport component which we will use to register a Service Worker. We will explore that along with custom configuration and build tooling in the second part of the article.

Lastly, we create a custom Document component as well. This component is rendered on the server side and is usually used to implement behaviours like changing <html> or <body> attributes, implement server-rendering of CSS-in-JS solutions, etc. Any application logic should not reside here. We use it to add a lang attribute to our server-rendered HTML response.

Things to do next

In the second part, we will extend the webpack config using next.config.js file under the root directory to support CSS usage, purging unused CSS, prefixing our CSS, generating Web App Manifest for the PWA and generating Service Worker code for pre-caching as well as runtime caching. We will also set up a custom server for enabling advanced features like ssr-caching and supporting pretty URLs for parameterized routing (which we are using for our movie details page). All the good stuff that will make our Web App "Progressive".


Async Await Joe picture

Any advice I'm getting manifest.json 404 not found and service worker not able to be registered. Btw cloned repo and I get that error.

Soumyajit Pathak picture

Can you please share a little more info about the error? Or may be create a issue in the repo mentioning the whole scenario?

naveen2646 picture

Hi, great article! I was wondering if there's any way where I could provide inline manifest JSON using encodeURIComponent? I did it in angular the same way and it worked but in next js, its not working at all.. I could see the link tag with correct data in header when I inspect data and also the PWA worked as expected when I gave href as manifest.json which was stored in public folder.