I start a lot of projects that never see the daylight. One of those projects was a tracker of prices of a specific store, which had a single page web application to consult the results, developed using Angular. I wanted to make it available on the Internet, and so I decided to change the project to use Angular Universal for a couple of reasons.

1. Facilitate web crawlers through search engine optimization (SEO)
2. Improve performance on mobile and low-powered devices
3. Show the first page quickly with a first-contentful paint (FCP)

(Source)

Firebase is a platform for web and mobile development. I usually choose it to host my personal projects for two major reasons:

  • The Spark plan (free tier) is pretty generous, and provides all the services I need - a key-value database (Cloud Firestore), hosting for static websites, and a serverless execution environment (Cloud Functions);
  • AngularFire, the "official library for Firebase and Angular", is a great library that is easy to use and has support for most Firebase functionalities.

Before starting, make sure you have Node.js, Angular and Firebase Tools installed.

Table of Contents

Adding Angular Universal to a project

To use Angular Universal to an existing Angular project, we add the published package @nguniversal/express-engine using the following command:

ng add @nguniversal/express-engine --clientProject myProjectName

This adds some new, important files to the project. There are three new files related to the Angular Universal app, which is used to render content on the server-side:

  • tsconfig.server.json
  • src/main.server.ts
  • src/app/app.server.module.ts

Also, there are files for the Express.js server, which will be used to handle requests and responses:

  • server.ts
  • webpack.server.config.js

Testing locally

In package.json you'll notice there are four new scripts related to Universal. To run the app locally, run the following scripts:

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

The app should now be running on localhost:4000.

Some errors may occur, such as missing XHLHttpRequest. To solve that, we need to install some polyfills.

Polyfills are pieces of code that implement features on web browsers that do not support those features. Firebase uses Websockets and XHR not included in Angular that we need to polyfill [2].

npm install ws xhr2 bufferutil utf-8-validate -D

Initializing a Firebase project

Assuming you've installed Firebase tools, initialize a Firebase project in the same folder as the Angular app with the Hosting and Cloud Functions services. Feel free to take a break and go read about these services if you are not familiar with the Firebase environment.

firebase init
# Select: hosting, functions

Now, let's redirect all traffic from hosting to a function, which we will call ssr.

// firebase.json

"hosting": {
    "public": "dist/browser",
    // ...
    "rewrites": [
    	{
        	"source": "**",
        	"function": "ssr"
      	}
    ]
}

Removing the Express Server Listener and updating the Webpack config

In a normal server, we would just run server.ts using Node.js and forget about it, making Express.js deal with all the requests. Cloud Functions already do that under the hood, so our application does not need to listen for requests, therefore we have to change the code. In server.ts remove or comment out the request listener.

// server.ts
// Remove these lines 👇

// Start up the Node server
// app.listen(PORT, () => {
//   console.log(`Node Express server listening on http://localhost:${PORT}`);
// })

In webpack.server.config.js, update the "output" property. These changes tell Webpack to package the server code as a library so it can be consumed by a Cloud Function.

output: {
    path: path.join(__dirname, 'dist'),
    library: 'app',
    libraryTarget: 'umd',
    filename: '[name].js',
}

Rebuild the app again using npm run build:ssr.

Copy the Angular app to the Function environment

Let's automate copying the app to the Function environment. Navigate to the functions directory and install a necessary package: fs-extra.

cd functions
npm i fs-extra

Next, we'll create the script with the name cp-angular.js, and then update the build script to automate the copy in functions/package.json.

// functions/cp-angular.js

const fs = require('fs-extra');

(async() => {

    const src = '../dist';
    const copy = './dist';

    await fs.remove(copy);
    await fs.copy(src, copy);

})();
// functions/package.json

{
  "name": "functions",
  "engines": {
    "node": "8"
  },
  "scripts": {
    "build": "node cp-angular && tsc"
  }
}

Now we need to create the Cloud Function. We need to state that whenever an HTTPS request is made to our server, it is redirected to our Angular Universal app.

// functions/index.ts

import * as functions from 'firebase-functions';
const universal = require(`${process.cwd()}/dist/server`).app;

export const ssr = functions.https.onRequest(universal);

Deploying the application

All there's left to do is build both Angular and Firebase projects, and serve the latter. If everything looks good, deploy it!

# /
npm run build:ssr
cd functions

# /functions
npm run build
cd ..

# /
firebase serve

# If everything is ok
firebase deploy

Final thoughts

In my opinion, it turned out to be quite difficult to deploy an Angular Universal application to the Firebase Cloud Functions. I've decided to go with Functions as I wanted to keep all my assets on the same platform, but there are easier alternatives: you can deploy it to a common VPS or use a serverless computing platform such as Google App Engine (which deployment is covered in Angular Universal SSR with Firebase).

Sources: [1] [2]