AMP is a way to build pages for static content that render fast. Progressive Web Apps are reliable, fast and engaging experiences on the web. There are several ways in which a developer can use AMP components to build a PWA experience in their website.

What are we going to be building?

This codelab will walk you through implementing two different AMP + PWA patterns, by transforming a canonical AMP site into a Progressive Web App, and then enhancing it using the AppShell architecture.

What you'll learn

What you'll need

This codelab is focused on AMP and Progressive Web Apps. Non-relevant concepts and code blocks are glossed over and are provided for you to simply copy and paste.

Download the Code

Click the following link to download all the code for this codelab: Download. Alternatively, clone the project on Github.

git clone https://github.com/googlecodelabs/amp-pwa.git

Install Node.js

Throughout the codelab, we're going to use a tool to help us generate the Service Worker. This tool is called sw-precache and requires Node.js to be installed. Check the Node.js website on how to install it on your Operating System.

Serving content from localhost

For this codelab, you will need to be able to serve content from the local filesystem. Our tool of choice throughout the codelab will be serve, a Node.js based static content server. To install it, run:

npm install -g serve

Run the starter code

Test your setup to make sure you have all the pieces in place. On the console, navigate to the directory where the codelab source code was installed and go to the /work folder. This will be the folder that we will be using throughout the codelab. To start a server, just the serve inside the work folder.

Now, open Chrome and navigate to http://localhost:3000 and check if you are seeing our AMP index page.

We will start by adding a service worker that caches static content, such as logo images and fonts for the application.

Install sw-precache, a tool created by Jeffrey Posnick that greatly simplifies creating Service Workers.

npm install -g sw-precache

On the root of the work folder, create a file and name it sw-precache-config.js. Add the following content to the file:

module.exports = {
  staticFileGlobs: [
    'img/**.*'
  ]
};

This will configure sw-precache to create a Service Worker that caches everything under the images directory and everything in the fonts directory.

Now, run sw-precache:

sw-precache --config=sw-precache-config.js

It will generate a service-worker.js file that is setup to cache images and fonts. Every time something is added or changed in those folders, the service worker should be regenerated with the previous command.

Generating the service-worker is only half the task. The next step is installing the Service Worker from the AMP pages. AMP provides the amp-install-serviceworker component to this end:

Add the Service Worker Javascript to the end of the head section of each of the AMP pages.

<script async custom-element="amp-install-serviceworker" 
  src="https://cdn.ampproject.org/v0/amp-install-serviceworker-0.1.js">
</script>

We also need to add the component configuration to the bottom of AMP each page, right before the </body> tag:.

<amp-install-serviceworker 
  src="/service-worker.js" 
  layout="nodisplay">
</amp-install-serviceworker>  

Installing the Service worker from the AMP Cache

AMP won't be able to install the Service Worker from the AMP Cache using the javascript file. In order to allow this, the amp-install-serviceworker component has an extra attribute called data-iframe-src that loads that URL as an iframe and allows the Service Worker installation from the AMP Cache.

Create a file on the root of your work folder and name it install-service-worker.html, and add the following content:

<!doctype html>
<html>
  <head>
    <title>installing service worker</title>
    <script type="text/javascript">
        var swsource = "/service-worker.js";
        if("serviceWorker" in navigator) {
          navigator.serviceWorker.register(swsource)
            .then(function(reg){
              console.log('SW scope: ', reg.scope);
            })
            .catch(function(err) {
              console.log('SW registration failed: ', err);
            });
        };
    </script>
  </head>
  <body>
  </body>
</html>

This code will perform the Service Worker registration when this page is loaded from inside an iframe. Now, return to the amp-install-serviceworker component in the AMP files and add reference to this page.

The amp-install-serviceworker configuration should look like the following:

<amp-install-serviceworker 
  src="/service-worker.js" 
  layout="nodisplay"
  data-iframe-src="/install-service-worker.html">
</amp-install-serviceworker>

Test it out

Make sure all files have been saved on your editor, and start a server by running serve from the work folder.

Navigate to http://localhost:3000 using Chrome. Open Chrome Developer Tools by going to the Chrome Menu > More Tools > Developer Tools, or by using the shortcuts, Ctrl + Shift + I or Cmd + Option + I on a Mac.

Inside Chrome Developer Tools, navigate to the Applications tab and click on the Service Worker item.

You should see information that the Service Worker has been installed, like the above.

Congratulations! You just took the first step towards transforming your AMP site into a Progressive Web App by creating and installing a Service Worker that caches static content.

Caching visited pages

In order to cache pages the user has visited, we need to add another configuration to sw-precache, on the sw-precache-config.js. Add the code below, right after the staticFileGlobs attribute.

runtimeCaching: [{
  urlPattern: '*',
  handler: (request, values, options) => {
    // If this is NOT a navigate request, such as a request for
    // an image, use the cacheFirst strategy.
    if (request.mode !== 'navigate') {
      return toolbox.cacheFirst(request, values, options);
    }

    // If it's a navigation request, use the networkFirst strategy.
    return toolbox.networkFirst(request, values, options);
  }
}]

The runtimeCaching attribute receives an array of of handler configurations. This particular one is catching any request, and it's using the request.mode attribute to check if it's a request for a page or a request for a resource, and using different caching strategies for each.

Add an offline page

Now, when a visitor goes to a previously visited page while offline, they will be able to see a cached version of the page. But if the user clicks on a link that wasn't previously visited, they will still get the offline page. Using a Service Worker, it's possible to customise the offline page:

Create an offline.html file inside your work folder. Here's a simple offline page

<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <title>The Photo Blog - Offline</title>
  <meta name="viewport"
        content="width=device-width,minimum-scale=1,initial-scale=1">
</head>
<body>
  <h1>You are Offline</h1>
</body>
</html>

Update the sw-precache configuration by editing sw-precache-config.js. First, add the offline page to the list of static files to cache on the staticFileGlobs:

module.exports = {
  staticFileGlobs: [
    'img/**.*',
    'offline.html'
  ],
...

We already have a call to toolbox.networkFirst on our handler, that returns a Promise. This Promise is rejected if the if the network is not available and the file is not found on the cache. Add a catch section after the toolbox.networkFirst method call to return the offline page when a request fails:

...
if (request.mode !== 'navigate') {
  return toolbox.cacheFirst(request, values, options);
}

// If it's a request for content, use the networkFirst
// strategy, and send an offline page if both network and
// cache fail.
return toolbox.networkFirst(request, values, options)
  .catch(() => {      
    return caches.match('/offline.html', {ignoreSearch: true});
  });      
...

Caching the AMP runtime

At this point, when trying to open one of the cache pages offline, you should be seeing a broke pages with a bunch of errors on the console:

This happens because, although the pages and supporting content were cached, the AMP runtime wasn't. Let's update our sw-precache config to handle the AMP runtime too. Add the following code the the array of runtimeCaching:

{
  urlPattern: /cdn\.ampproject\.org/,
  handler: 'fastest'
}

This will cache the AMP Runtime using the fastest strategy, This strategy will request the resource from both the cache and the network, in parallel. The cached version will be the one that is usually returned, if available. But the parallel networks request will ensure the cache is updated with the latest version of the AMP runtime, so it will be served on the next request.

The full sw-precache-config.js should look like the following, at this point:

module.exports = {
  staticFileGlobs: [
    'img/**.*',
    'offline.html'
  ],
  runtimeCaching: [{
    urlPattern: '*',
    handler: (request, values, options) => {
      // If this is NOT a navigate request, such as a request for
      // an image, use the cacheFirst strategy.
      if (request.mode !== 'navigate') {
        return toolbox.cacheFirst(request, values, options);
      }

      // If it's a request for content, use the networkFirst
      // strategy, and send an offline page if both network and
      // cache fail.
      return toolbox.networkFirst(request, values, options)
        .catch(() => {      
          return caches.match('/offline.html', {ignoreSearch: true});
        });      
    }
  }, {
    urlPattern: /cdn\.ampproject\.org/,
    handler: 'fastest'
  }]
};

Don't forget to regenerate the Service Worker by running:

sw-precache --config=sw-precache-config.js

Test it out

Navigate to http://localhost:3000 and wait for the page to load.

Open Chrome Developer Tools and navigate to the Application tab. On the Cache section, right click on Cache Storage and click refresh. Check the caches and verify if the files necessary for the page are listed in the cache.

On the Service Worker section, click on the Offline checkbox and reload the page. You should see that the page loaded even though it's offline. Now, click on one of the links to articles. Since the article wasn't cached you should see the custom offline page.

One of the great advantages of a PWA is being eligible to be added to the Home Screen. For an app to be eligible to do so, it's required to add a Web Manifest to the application.

Those are the manifest attributes that make an app eligible to be added to the Home Screen, at the time of this writing:

Generating the Web Manifest

Writing a Web Manifest by hand can be an error prone task. There are several online manifest generators available on the web that can help with this task. We are going to use the one at https://app-manifest.firebaseapp.com/ to generate ours.

Fill the form fields with the desired values. Here's what we are using for this codelab:

The next step is upload the icon to the generator. Click on the ICON button, navigate to the work folder and upload the icon on /icons/web_hi_res_512.png.

Click on the GENERATE ZIP button to download icons generated in multiple sizes and the full manifest.

Extract the downloaded zip file and copy its contents your project, so that the manifest.json file is on the root of the work directory.

Linking the manifest

To make the browser aware of the manifest, we need to link our pages to each. This is achieved by adding a link tag to the head of your pages.

<link rel="manifest" href="/manifest.json">  

Making sure start_url is cached

The last requirement that needs to be met for the home screen to work is that the URL referenced on start_url must always be available, even when the user is offline. To meet this requirement, we can ensure that it will always be available by pre-caching the url when the Service Worker is installed and use the networkFirst cache strategy to ensure it's always fresh.

Since the index.html page is of a more dynamic nature, when compared to the an offline page, we need to use a different approach to pre-cache the page. We'll take advantage of sw-precache's extensibility to do it.

On the sw-precache-config.js file, add another configuration property:

importScripts: ['service-worker-import.js']

This will add an importScript call to the generated Service Worker. Now, create a service-worker-import.js file on the root folder, and add this line of code:

toolbox.precache(['/index.html']);

When the imported script is invoked by the generated Service Worker, it will add the index.html page to the runtime cache. By doing this, we ensure the page will be available for the existing handler we created earlier.

Run sw-precache to update the generated Service Worker:

sw-precache --config=sw-precache-config.js

Test it out

On Chrome Developer Tools, navigate to the Application tab and check the Manifest item under the Application section. The information contained on the Manifest should be displayed on the right side.

Extra Credits

You may have noticed that, although the index.html page has been cached, the images for the articles were not. Although it is possible to pre-cache the content, the dynamic nature of the index page makes it harder to know which images to cache. A possible solution for this is to serve a fallback offline image, when the requested one is not available. This is an exercise to the reader.

The AMP as Canonical PWA

Congratulations, you've just implemented your first AMP as Canonical PWA. You have made your application more resilient to offline scenarios, optimized loading performance by caching static assets and images and became eligible to be launched from the home screen!

Although the AMP as a Canonical PWA architecture may be enough in many scenarios, developers may need to use features that are not yet supported in AMP, such as Push Notifications or the Credentials Manager API.

For those scenarios, the AMP in a PWA architecture can be used.

Replacing requests with the shell

Let's start by adding an App Shell to our application. We will then change our Service Worker, so that once installed, it will catch any navigation requests and replace them with the shell.

Create a file called shell.html in the work folder:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width,minimum-scale=1,initial-scale=1">  
    <title>AMP => PWA Demo</title>
    <style type="text/css">
body{margin:0;padding:0;background:#F5F5F5;font-size:12px;font-weight:300;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen-Sans,Ubuntu,Cantarell,"Helvetica Neue",sans-serif}a{text-decoration:none;color:#000}.header{color:#fff;background:#1976D2;padding:8px 16px;box-shadow:0 2px 5px #999;height:40px;display:flex;align-items:center}.header h1{margin:0 8px 0 0}.header amp-img{margin-right:8px}.header img{margin-right:8px}.header a{color:#fff}.header a:visited{color:#fff} 
    </style>
  </head>
  <body>
    <header class="header">
      <img src="/img/amp_logo_white.svg" width="36" height="36" />
      <h1><a href="/">AMP PWA Codelab - PWA</a></h1>
    </header>    
    <div id="amproot">
      <!-- AMP Content should appear here! -->
    </div>
  </body>
</html>

Update the sw-precache-config.js to serve the created shell.html for navigate requests. Here's how the full config should look like:

module.exports = {
  staticFileGlobs: [
    'img/**.*',
    'offline.html',
    'shell.html',
    'js/**.js'
  ],
  runtimeCaching: [{
    urlPattern: '*',
    handler: (request, values, options) => {
      // If this is NOT a navigate request, such as a request for
      // an image, use the cacheFirst strategy.
      if (request.mode !== 'navigate') {
        return toolbox.cacheFirst(request, values, options);
      }

      return caches.match('/shell.html', {ignoreSearch: true});  
    }
  }, {
    urlPattern: /cdn\.ampproject\.org/,
    handler: 'fastest'
  }],
  importScripts: ['service-worker-import.js']
};

The main handler was changed, so that any navigation request replaced with the App shell.

Loading AMPs with amp-shadow

For this step, you will be using the a javascript file, located at js/app.js, inside the work directory. This file already contains some boilerplate code needed for the shell to word: a method to fetch AMP documents from the backend and a Promise that allows to schedule code to be ran when the amp-shadow component is loaded.

To get started, add the amp-shadow component to the head section of the App Shell:

<!-- Asynchronously load the AMP-Shadow-DOM runtime library. -->
<script async src="https://cdn.ampproject.org/shadow-v0.js"></script>    

At the bottom of the shell, import the js/app.js script:

<script src="/js/app.js" type="text/javascript" defer></script>

Now, open the app.js script and add code that to set up the variables that are needed to add the AMP content to our amproot div.

const ampRoot = document.querySelector('#amproot');
const url = document.location.href;
const amppage = new AmpPage(ampRoot, router);
ampReadyPromise
  .then(() => {
    amppage.loadDocument(url);
  });

The original request was replaced by the Service Worker with the content from shell.html, but the URL is still the same. We need to fetch the content from this URL to inject into our AMP root. So, we wait for the ampReadyPromise to resolve and load the content from the URL.

Let's implement the loadDocument method. You should find the placeholder for it a few lines above.

loadDocument(url) {
  return this._fetchDocument(url)
    .then(document => {
      router.replaceLinks(document);
      window.AMP.attachShadowDoc(this.rootElement, document, url);            
    });       
}

The document is fetched from the URL, and once it's resolved we call the amp-shadow API to attach it to the document.

Manipulating the content of the AMP file

When injecting the AMP pages in the App Shell, it's likely that developers will need to add, remove or change sections of the AMP document.

The fetched document is a regular DOM document, so a developer can manipulate it as needed. On the snippet below, we are removing the Header element from the AMP document, as the header is already present on the App Shell and ends up being duplicated.

loadDocument(url) {
  return this._fetchDocument(url)
    .then(document => {
      const header = document.querySelector('.header');
      header.remove();
      router.replaceLinks(document);
      window.AMP.attachShadowDoc(this.rootElement, document, url);            
    });       
}

Test it out

Open Chrome and navigate to http://localhost:3000. When opening the first page, the browser should render the AMP page, as the Service Worker is not yet installed. By reloading the page, the App Shell version should be rendered.

In the current implementation, when the user navigates the site on a browser that doesn't support Service Workers, they will never see the App Shell experience. Fortunately, the amp-install-serviceworker component provides a fallback that rewrites links on the page to the shell URL.

On the AMP pages, update the amp-install-serviceworker component to include the fallback attributes:

  <amp-install-serviceworker 
    src="/service-worker.js" 
    layout="nodisplay"
    data-iframe-src="/install-service-worker.html"
    data-no-service-worker-fallback-url-match=".*"
    data-no-service-worker-fallback-shell-url="/shell.html">
  </amp-install-serviceworker>  

Now, when the user using a browser that doesn't support Service Workers clicks on a link inside the AMP files, the AMP runtime will replace the link with the shell URL and append the original URL as a fragment, in the format #href=<original url>.

So, a link to /articles/1.html becomes /shell.html#%2Farticles%2F1.html. Let's make our App Shell code aware of this change. Add a method to find out the correct url for the content

...
function getContentUri() {
  const hash = window.location.hash;
  if (hash && hash.indexOf('href=') > -1) {          
    return decodeURIComponent(hash.substr(6));
  }
  return window.location;  
}
...
const ampRoot = document.querySelector('#amproot');
const url = getContentUri();
const amppage = new AmpPage(ampRoot, router);

If the user is coming from the service worker, the url fragment will be present, so we can extract the final URL using decodeURIComponent. If the content of the URL was replaced by the Service Worker, we already have the correct URL. The assignment to the url variable is replaced with a call to the new method.

Fixing broken URLs

A side effect of using the URL Rewrite fallback is that when a user clicks on a link inside the shell, the URL in the url bar will show the shell url with the fragment. We can use the Page History API to fix it.

ampReadyPromise.then(() => {
  return amppage.loadDocument(url);
})
.then(() => {
  if (window.history) {
    window.history.replaceState({}, '', url);
  }
});

Once the loadDocument Promise successfully resolves, we replace the shell URL with the one that was just loaded.

Test it out

Start a browser that doesn't support Service Workers. At the time of this writing, examples are Edge on Windows and Safari on OSX. Navigate to http://localhost:3000. The first view should be an AMP page. It's possible to verify it by opening the Developer Tools on that browser and checking the source code. Now, click on one of the links on the initial page. The URL should have been rewritten, and the page being rendered using the App Shell.

Congratulations!

You've just finished implementing your first AMP in a PWA. Your application has all the advantages of the AMP as a Canonical PWA pattern, and you are now able to add features that were only possible in AMP.

What's Next?

This codelab only scratches the surface with regards to with what is possible when building Progressive Web Apps with AMP. When building a more complex experience, be sure to check the application design against the PWA Checklist. Also, check Your First Progressive Web App codelab, to learn the more about the details on how the Service Worker works.