How to setup a universal PWA in angular 8

angular
PWA
angular universal
server-side rendering
service workers

©OneCodex 28.01.2020

Nowadays people are more and more tending to do everything using their smartphone. That's why a lot of companies decide to implement their web pages as progressive web apps (PWA). These are web sites which behaves like a native app (when added to homescreen) but in fact are using the mobile's browser in the background.

In case of angular it's meant to be implemented using angular service workers. For companies which want to be found by search engines, this is not an optimal setup since the crawlers of the search engines aren't really able to handle a complete javascript based application although they promise they do. That's why I recommend using angular universal as well. It is angular's implementation of server-side rendering.

While setting up my application I couldn't find a really good tutorial on how to set up both in one single application, so I decided to share the experience I made. (Please note that I don't mention auto generated settings in this post so you should also check the angular universal docs  as well as the angular service-workers docs). I would recommend starting with the default getting started guides from the links above to understand the basics and then come back again to this blog post.

Prerequisites

For using angular universal as well as angular service workes you'll need to have a server which node is running on because the default setup uses an express server. In my case I am using the following server setup:

  • nginx
  • letsencrypt
  • debian 9 jessy
  • angular 8

What does server-side rendering mean?

Usually when using any single page application framework or library (angular, react, vue, ...), the application is served as an index.html file which only contains references to javascript and css files and one root node which all the content is added to dynamically during runtime. As you can imagine, this is not really useful when you need to be found at google, bing and other search engines. So people invented server-side rendering which enables processing of javascript on the server to deliver most of the content in a static html file. Therefore some kind of application server is needed which is able to process the javascript code and return the pre-rendered html.

Getting started

Ok, so you know what this is all about so let's start implementing. First of all we need to create a new angular app (in my case angular 8).

ng new my-angular-universal-pwa

After having created the new angular application, we want to initialize service workers and server-side rendering.

ng add @angular/pwa --project my-angular-universal-pwa
Read more about angular service workers
ng add @nguniversal/express-engine --clientProject my-angular-universal-pwa
Read more about angular universal

The next step is not required but if you want to use fxFlexLayout you should double check, that the following configuration is set up.


//app.server.module.ts
import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader';
import { FlexLayoutServerModule } from '@angular/flex-layout/server';
@NgModule({
  imports: [
    ...
    ModuleMapLoaderModule,
    FlexLayoutServerModule,
  ]
})

//app.module.ts
import { FlexLayoutModule } from '@angular/flex-layout';
import { DeviceDetectorModule } from 'ngx-device-detector';

@NgModule({
  declarations: [
    ...
  ],
  imports: [
    BrowserModule.withServerTransition({ appId: 'serverApp' }),
    FlexLayoutModule.withConfig({
      ssrObserveBreakpoints: [
        'xs',
        'sm',
        'lt-sm',
        'gt-sm',
        'gt-md',
        'lt-md',
        'md',
      ],
	}),
	ServiceWorkerModule.register('ngsw-worker.js', {
		enabled: true,
	}),
	DeviceDetectorModule.forRoot()
	

Since we're using server-side rendering you can see that the application includes two different app.module files. One for the server side and another one for the client side. The server doesn't know anything about client sizes, we explicitely need to import FlexLayoutServerModule in our app.server.module.ts and declare the sizes we want to use in our application using the FlexLayoutModule.withConfig function and set the ssrObserveBreakpoints array in app.module.ts.

I also added the configuration for DeviceDetectorModule, here which I will need later on for detecting which kind of device the client is using. This is also not required but in my opinion it's good to know.

Exclude parts of our code from being executed on server-side

Since we're using server-side rendering and there are no window or document objects accessible on server side we need to ensure that these are not used in our code until we're in the browser.

To exclude sections of our code we're first creating a service which detects the different environments we're in. In the following snippet the service is placed in a shared module since I'm using lazy loading but you can set up the service location as you wish.

ng g service modules/shared/services/platform

import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Injectable({
  providedIn: 'root',
})
export class PlatformService {
  private isBrowser: boolean;

  constructor(@Inject(PLATFORM_ID) private readonly platformId: any) {
    this.isBrowser = isPlatformBrowser(this.platformId);
  }

  isPlatformBrowser(): boolean {
    return this.isBrowser;
  }

  isUserAgent(): boolean {
    if (this.isBrowser) {
      const botPattern =
        // tslint:disable-next-line: max-line-length
        '(googlebot/|Googlebot-Mobile|Googlebot-Image|Google favicon|Mediapartners-Google|bingbot|slurp|java|wget|curl|Commons-HttpClient|Python-urllib|libwww|httpunit|nutch|phpcrawl|msnbot|jyxobot|FAST-WebCrawler|FAST Enterprise Crawler|biglotron|teoma|convera|seekbot|gigablast|exabot|ngbot|ia_archiver|GingerCrawler|webmon |httrack|webcrawler|grub.org|UsineNouvelleCrawler|antibot|netresearchserver|speedy|fluffy|bibnum.bnf|findlink|msrbot|panscient|yacybot|AISearchBot|IOI|ips-agent|tagoobot|MJ12bot|dotbot|woriobot|yanga|buzzbot|mlbot|yandexbot|purebot|Linguee Bot|Voyager|CyberPatrol|voilabot|baiduspider|citeseerxbot|spbot|twengabot|postrank|turnitinbot|scribdbot|page2rss|sitebot|linkdex|Adidxbot|blekkobot|ezooms|dotbot|Mail.RU_Bot|discobot|heritrix|findthatfile|europarchive.org|NerdByNature.Bot|sistrix crawler|ahrefsbot|Aboundex|domaincrawler|wbsearchbot|summify|ccbot|edisterbot|seznambot|ec2linkfinder|gslfbot|aihitbot|intelium_bot|facebookexternalhit|yeti|RetrevoPageAnalyzer|lb-spider|sogou|lssbot|careerbot|wotbox|wocbot|ichiro|DuckDuckBot|lssrocketcrawler|drupact|webcompanycrawler|acoonbot|openindexspider|gnam gnam spider|web-archive-net.com.bot|backlinkcrawler|coccoc|integromedb|content crawler spider|toplistbot|seokicks-robot|it2media-domain-crawler|ip-web-crawler.com|siteexplorer.info|elisabot|proximic|changedetection|blexbot|arabot|WeSEE:Search|niki-bot|CrystalSemanticsBot|rogerbot|360Spider|psbot|InterfaxScanBot|Lipperhey SEO Service|CC Metadata Scaper|g00g1e.net|GrapeshotCrawler|urlappendbot|brainobot|fr-crawler|binlar|SimpleCrawler|Livelapbot|Twitterbot|cXensebot|smtbot|bnf.fr_bot|A6-Indexer|ADmantX|Facebot|Twitterbot|OrangeBot|memorybot|AdvBot|MegaIndex|SemanticScholarBot|ltx71|nerdybot|xovibot|BUbiNG|Qwantify|archive.org_bot|Applebot|TweetmemeBot|crawler4j|findxbot|SemrushBot|yoozBot|lipperhey|y!j-asr|Domain Re-Animator Bot|AddThis)';
      const re: RegExp = new RegExp(botPattern, 'i');

      return !re.test(navigator.userAgent);
    } else {
      return false;
    }
  }
}

So we now have the method which detects whether we're on the server or on the client-side. I also added a method to determine if it's a search engine so we can handle as well.

Now we're able to exclude some parts of our code to be executed on server-side. For example the initialization of an auth0 client. This is necessary since it's using crypthography modules that are only available in the browser.


ngOnInit(): void {
    if (this.platformService.isPlatformBrowser()) {
      this.auth.init();
      this.auth.localAuthSetup();
    }
}
			

Please notice if you're using any objects which are browser specific you'll always have to surround their usage with this check.

Handle application updates in PWAs

Using a progressive web app, the clients will be able to download the website to their smartphones (using the 'add to home screen' functionality) and use the whole website offline (except from data being served by an API).

To ensure that the clients will recognize that there's a new version of the website and be able to update we need to implement update functionality from angular service workers.

So again we're creating a new service

ng g service modules/shared/services/update

Next we're going to implement the service which subscribes to update events from service worker.


import { Injectable } from '@angular/core';
import { SwUpdate } from '@angular/service-worker';
import { SnackbarService } from './snackbar.service';

@Injectable()
export class UpdateService {
  constructor(
    private updates: SwUpdate,
    private snackbarService: SnackbarService
  ) {}

  subscribe(): void {
    this.updates.available.subscribe(event => {
		this.snackbarService.action('A new version of the app is available', 'download', () => {
		this.updates.activateUpdate().then(() => document.location.reload());
		});
    });
  }
}

			

The snackbar service we're using here is just an abstraction of material's snackbar. Now I'm going to explain what we're doing here. 'this.updates.available' is a default functionality provided by angular service workers. It detects if there's a newer version of the app and subscribe to this event. If so, we provide an action snackbar to the user where he can decide whether he wants to update or not. If so we execute the 'activateUpdate' method followed by a document.location.reload. This forces the application to update.

Handle API calls while server-side rendering process

Sometimes it makes sense to call APIs hosted on different domains while server-side rendering process. Therefore you need to adapt the UniversalInterceptor to prevent url rewrite for these urls. The following code sample is not similar to the ones you'll find on the web but for me this was the only solution that worked, correctly.


@Injectable()
export class UniversalInterceptor implements HttpInterceptor {
  constructor(
    @Optional() @Inject(REQUEST) protected request: Request,
    public platformService: PlatformService
  ) {}

  intercept(req: HttpRequest<any>, next: HttpHandler) {
    let serverReq: HttpRequest<any> = req;
    if (this.request) {
      let newUrl = '';
      if (req.url.indexOf(environment.services.apiUrl) < 0) {
		// you should use es6 template string in the following line
        newUrl = this.request.protocol + '://' + this.request.get('host'); 
        if (!req.url.startsWith('/')) {
          newUrl += '/';
        }
      }
      newUrl += req.url;
      serverReq = req.clone({ url: newUrl });
    }
    return next.handle(serverReq);
  }
}
			

Let's test

Once we implemented all this stuff we're ready to test and if successul deploy it to our server. For running the universal pwa on our local machine we use the following command

npm run build:ssr && npm run serve:ssr

If you don't want to wait for compilation each time you changed anything you'll still be able to use ng serve but you won't be able to test service workers and server-side rendering then. You'll then get errors in your browser console since service-workers cannot be called then.

Deploy on server

I am usually using a linux server distribution. In this example I explain how to deploy the app on a debian server. On a linux server it's pretty straight forward to setup a service using the system and service manager systemd.

So first let's create a service

sudo nano /etc/systemd/system/my-angular-universal-pwa.service

Now open the created file and add the following contents (Note: you must first have installed node on your server)


[Unit]
Description=My Angular Universal PWA

[Service]
WorkingDirectory=/var/www/[[OurFolderWhichContainsTheDistFolder]]
ExecStart=/usr/bin/node dist/server
Restart=always
# Restart service after 10 seconds if the service crashes:
RestartSec=10
KillSignal=SIGINT
SyslogIdentifier=my-angular-universal-service
User=www-data

[Install]
WantedBy=multi-user.target
			

Then register the service

sudo systemctl enable my-angular-universal-pwa.service

Start service and check status

sudo systemctl start my-angular-universal-pwa.service
sudo systemctl status my-angular-universal-pwa.service

If you want to read more about hosting applications on debian using nginx and letsencrypt, read the following blog post

Blog: How to deploy applications on debian using nginx and letsencrypt

Well done, our agular universal PWA is up and running. Pretty simple, isn't it?

If you have any questions or improvements please send me an email to

info[at]onecodex[dot]ch