How to Build a Progressive Web Application with JavaScript

·

8 min read

As technology advances and mobile usage continues to increase, building web applications that work seamlessly on both desktop and mobile devices is becoming a necessity. A Progressive Web Application (PWA) is a web app that offers a native app-like experience with features such as offline functionality, push notifications, and the ability to install the app to the home screen. In this article, we will explore how to build a Progressive Web Application with JavaScript.

The Basics of a Progressive Web Application

A PWA is built using web technologies such as HTML, CSS, and JavaScript. A few key concepts are essential to keep in mind when building a PWA:

  • Responsive Design: The app should be designed to work well on both desktop and mobile devices.

  • Service Workers: These are JavaScript files that enable offline functionality by caching app assets and data.

  • Web App Manifest: This is a JSON file that provides metadata about the app, such as its name, icon, and start URL.

  • HTTPS: To ensure a secure connection, a PWA must be served over HTTPS.

Building a Simple Progressive Web Application

Let's build a simple PWA that displays a list of movies using the Open Movie Database (OMDB) API. The app will allow users to search for movies, view movie details, and save their favorite movies for offline viewing.

Step 1: Setup

To get started, we need to create a new folder for our project and initialize a new Node.js project by running npm init in the terminal. We will also need to install some dependencies:

npm install express morgan axios
  • express is a Node.js framework for building web applications.

  • morgan is a middleware for logging HTTP requests.

  • axios is a Promise-based HTTP client for making API requests.

Step 2: Create the Server

Let's create a new file named server.js and add the following code:

const express = require('express');
const morgan = require('morgan');
const axios = require('axios');

const app = express();
const port = process.env.PORT || 3000;

app.use(morgan('dev'));

app.use(express.static(__dirname + '/public'));

app.listen(port, () => {
  console.log(`Server listening on port ${port}`);
});

This code sets up an Express server that listens on port 3000 (or the PORT environment variable if set) and serves static files from the public directory. We're using morgan to log HTTP requests to the console.

Step 3: Create the HTML and CSS

In the public directory, create a new file named index.html and add the following code:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8">
    <title>Movie App</title>
    <link rel="stylesheet" href="style.css">
  </head>
  <body>
    <header>
      <h1>Movie App</h1>
      <form id="search-form">
        <input type="text" id="search-input" placeholder="Search for a movie">
        <button type="submit">Search</button>
      </form>
    </header>
    <main>
      <ul id="movie-list"></ul>
    </main>
    <script src="app.js"></script>
  </body>
</html>

This code sets up the basic structure of our app with a search form and a list of movies. We're also including a style.css file and a app.js file.

In the public directory, create a new file named style.css and add the following code:

* {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

body {
  font-family: Arial, sans-serif;
  font-size: 16px;
}

header {
  background-color: #007bff;
  color: #fff;
  padding: 1rem;
}

h1 {
  margin: 0;
}

form {
  margin: 1rem 0;
}

input[type="text"] {
  padding: 0.5rem;
  border: none;
  border-radius: 3px;
  font-size: 1rem;
}

button[type="submit"] {
  padding: 0.5rem 1rem;
  border: none;
  border-radius: 3px;
  background-color: #007bff;
  color: #fff;
  font-size: 1rem;
  cursor: pointer;
}

ul {
  list-style: none;
  margin: 0;
  padding: 1rem;
}

li {
  border: 1px solid #ccc;
  border-radius: 3px;
  padding: 0.5rem;
  margin: 0.5rem 0;
  display: flex;
  justify-content: space-between;
  align-items: center;
}

li h2 {
  margin: 0;
  font-size: 1.2rem;
}

li p {
  margin: 0;
  font-size: 1rem;
}

This code styles our app with some basic CSS rules. The * selector applies the box-sizing rule to all elements on the page. We also define some styles for the header, search form, movie list, and movie items.

Step 4: Create the JavaScript

In the public directory, create a new file named app.js and add the following code:

const searchForm = document.getElementById('search-form');
const searchInput = document.getElementById('search-input');
const movieList = document.getElementById('movie-list');

searchForm.addEventListener('submit', event => {
  event.preventDefault();
  const searchTerm = searchInput.value;
  searchMovies(searchTerm);
});

function searchMovies(searchTerm) {
  const url = `https://www.omdbapi.com/?s=${searchTerm}&apikey=YOUR_API_KEY`;

  axios.get(url)
    .then(response => {
      const movies = response.data.Search;
      const movieItems = movies.map(movie => {
        return `
          <li>
            <div>
              <h2>${movie.Title}</h2>
              <p>${movie.Year}</p>
            </div>
            <button data-id="${movie.imdbID}">Details</button>
          </li>
        `;
      });
      movieList.innerHTML = movieItems.join('');
    })
    .catch(error => {
      console.error(error);
    });
}

This code defines three variables for the search form, search input, and movie list elements. We then add an event listener to the search form that prevents the default form submission behavior and calls the searchMovies function with the search term entered by the user.

The searchMovies function builds a URL for the OMDB API using the search term and our API key. We then use axios to make an HTTP GET request to the API and retrieve the list of movies. We loop over the movies and create HTML for each movie item, including a button with the movie's IMDb ID as a data attribute. We then set the innerHTML of the movie list to the HTML we created.

Step 5: Add Movie Details

Let's add some code to display the details of a movie when the user clicks the Details button. In app.js, add the following code:

movieList.addEventListener('click', event => {
  const button = event.target.closest('button');
  if (button) {
    const movieId = button.dataset.id;
    showMovieDetails(movieId);
  }
});

function showMovieDetails(movieId) {
  const url = `https://www.omdbapi.com/?i=${movieId}&apikey=YOUR_API_KEY`;

  axios.get(url)
    .then(response => {
      const movie = response.data;
      const html = `
        <h2>${movie.Title}</h2>
        <p><strong>Year:</strong> ${movie.Year}</p>
        <p><strong>Director:</strong> ${movie.Director}</p>
        <p><strong>Actors:</strong> ${movie.Actors}</p>
        <p><strong>Plot:</strong> ${movie.Plot}</p>
      `;
      const li = document.querySelector(`[data-id="${movieId}"]`);
      li.innerHTML += html;
      button.disabled = true;
    })
    .catch(error => {
      console.error(error);
    });
}

This code adds an event listener to the movie list element that listens for clicks on the Details button. When a button is clicked, we get the movie ID from its data-id attribute and call the showMovieDetails function.

The showMovieDetails function builds a URL for the OMDB API using the movie ID and our API key. We then use axios to make an HTTP GET request to the API and retrieve the details for the movie. We create HTML for the movie details and append it to the movie item element. We also disable the Details button so the user cannot click it again.

Step 6: Make the App Progressive

Now that we have a working web app, let's make it progressive. To do this, we will add a service worker that caches the app's assets and enables it to work offline.

In the public directory, create a new file named service-worker.js and add the following code:

const CACHE_NAME = 'movie-search-cache-v1';
const urlsToCache = [
  '/',
  '/index.html',
  '/style.css',
  '/app.js',
  '/favicon.ico',
  'https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap',
  'https://unpkg.com/axios/dist/axios.min.js',
];

self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME)
      .then(cache => {
        return cache.addAll(urlsToCache);
      })
  );
});

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request)
      .then(response => {
        if (response) {
          return response;
        }

        return fetch(event.request)
          .then(response => {
            if (!response || response.status !== 200 || response.type !== 'basic') {
              return response;
            }

            const responseToCache = response.clone();

            caches.open(CACHE_NAME)
              .then(cache => {
                cache.put(event.request, responseToCache);
              });

            return response;
          });
      })
  );
});

self.addEventListener('activate', event => {
  event.waitUntil(
    caches.keys()
      .then(cacheNames => {
        return Promise.all(
          cacheNames.filter(cacheName => {
            return cacheName.startsWith('movie-search-cache-') && cacheName !== CACHE_NAME;
          }).map(cacheName => {
            return caches.delete(cacheName);
          })
        );
      })

The CACHE_NAME constant is used to give a unique name to the cache that will be created, and urlsToCache is an array of URLs that we want to cache. In the install event listener, we open the cache, add the URLs to it, and wait until the cache is populated.

In the fetch event listener, we first check if the requested resource is in the cache. If it is, we return it from the cache. If it is not in the cache, we fetch it from the network, and if the response is valid (HTTP 200 status code and a basic response type), we clone the response, add it to the cache, and return it to the caller.

In the activate event listener, we clean up any old caches that are no longer needed.

To register the service worker, go back to index.html and add the following code at the bottom of the file, just before the closing body tag:

<script>
  if ('serviceWorker' in navigator) {
    window.addEventListener('load', () => {
      navigator.serviceWorker.register('/service-worker.js')
        .then(registration => {
          console.log('Service worker registered:', registration);
        })
        .catch(error => {
          console.error('Service worker registration failed:', error);
        });
    });
  }
</script>

This code checks if the browser supports service workers and if so, registers the service worker with the browser. When the service worker is registered, the load event fires, and we log a message to the console indicating that the service worker has been registered.

Quick Recap and Challenge

In this article, we have built a progressive web app with JavaScript that allows users to search for movies and display details about them. We have used best practices for building web apps, such as separating concerns, handling errors, and optimizing performance.

To take this app further, you can add more features, such as a watchlist or a feature to save favorite movies. You can also optimize the app's performance by lazy-loading images or using a different API that returns smaller responses.

Building progressive web apps is an exciting and fast-evolving field. By following the best practices outlined in this article and experimenting with new features and technologies, you can create web apps that are both performant and engaging for your users.

Did you find this article valuable?

Support Clint by becoming a sponsor. Any amount is appreciated!