Angular Universal SSR with Firebase

Nothing beats the user experience of a single page JS app on the web, but you sacrifice the ability to share metatags with social media bots and search engines on deep links. Fortunately, you can overcome this limitation with server-side rendering (SSR) via Angular Universal.

The following lesson will show you how to setup Angular Universal with ExpressJS. In addition, you will learn how to deploy the app with (1) Node via AppEngine or (2) Firebase Cloud Functions - both of which are are on the free tier.

Step 0: Prerequisites

  1. Install Angular
  2. Install AngularFire
  3. Install Google Cloud SDK
  4. Install Firebase Tools

What is Angular Universal?

Angular is a client-side framework designed to run apps in the browser. Universal is a tool that can run your Angular app on the server, allowing fully rendered HTML to be served on any route. After the initial page load, Angular will take over and use the router for all other route changes. This primary use cases include search engine optimization (SEO), visibility with social linkbots, and performance optimization.

I highly recommend using NVM with Node v8.14.0 in your local environment. This is the version running on AppEngine and Cloud Functions and you might get unexpected errors on other versions.

Step 1: Setup Universal in Angular

NG Add Universal

command line
ng add @nguniversal/express-engine --clientProject myapp

This will add several new files to your project. The Angular Universal app is used to render your angular code on a server:

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

Then ExpressJS is the actual server that will handle requests/responses and is defined by these files:

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

Step 2: Render Dynamic Titles and Meta Tags in Angular

Our angular app needs a router-loaded component that generates its own metatags. The following example will hard code the meta tags, but you can also build them dynamically by reading data from your database.

command line
ng g component about -m app

Configure the Router

At this point, let’s build a basic component that renders dynamic meta tags based on the route ID.

file_type_ng_component_ts app-routing.module.ts
import { Routes, RouterModule } from '@angular/router';
import { AboutComponent } from './about/about.component';

const routes: Routes = [
  { path: 'about', component: AboutComponent }
];

Create the Component

Angular has built-in services to change the title and metatags in the document body.

file_type_ng_component_ts about.component.ts
import { Component, OnInit } from '@angular/core';
import { Title, Meta } from '@angular/platform-browser';

@Component({
  selector: 'app-about',
  templateUrl: './about.component.html',
  styleUrls: ['./about.component.scss']
})
export class AboutComponent implements OnInit {
  data = {
    name: 'Michael Jordan',
    bio: 'Former baseball player',
    image: 'avatar.png'
  };

  constructor(private title: Title, private meta: Meta) {}

  ngOnInit() {
    this.title.setTitle(this.data.name);
    this.meta.addTags([
      { name: 'twitter:card', content: 'summary' },
      { name: 'og:url', content: '/about' },
      { name: 'og:title', content: this.data.name },
      { name: 'og:description', content: this.data.bio },
      { name: 'og:image', content: this.data.image }
    ]);
  }
}

Step 3: Compile/Test the Server Locally

Compile the Server

Open the package.json file and you’ll notice four new scripts related to SSR. Run the commands below to compiple the TypeScript code and run the Express server on localhost:4000.

command line
npm run build:ssr
npm run serve:ssr

At this point, you should see an error that looks like this because our server is thowing an error for missing XHLHttpRequest. See the next step to fix it.

broken universal app

Add Firebase Polyfills to Express

Firebase uses Websockets and XHR not included in Angular that we need to polyfill.

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

Then declare them on Node global at the top of the server file.

file_type_typescript server.ts
(global as any).WebSocket = require('ws');
(global as any).XMLHttpRequest = require('xhr2');

// ...

Rebuild your app and restart the server. The HTML returned from Express should now include custom meta tags.

working universal app

Deploy Option A - AppEngine Node Standard

Command Line Deploy

Deploying to AppEngine will containerize your code allow it to scale infinitely in the cloud. It starts on a free tier and can scale up automatically based on traffic or resource demands.

Crate an app.yaml file in the root the project. The standard environment for Node8 and Node10 is free to deploy with a small instance and can automatically scale the moon 🌙 as needed.

file_type_light_yaml app.yaml
runtime: nodejs8

With Google Cloud SDK installed on your system, simply run the deploy command.

command line
gcloud app deploy

Also, update the start command in the package.json to run Express server.

file_type_npm package.json
{
  "scripts": {
    "start": "npm run serve:ssr"
  }
}

If all went according to plan, you should now see your app on the AppEngine dashboard.

angular universal app deployed to app engine

Deploy Option B - Firebase Cloud Functions

An alternative to AppEngine is to rewrite your Firebase Hosting rules to a Firebase Cloud Function.

command line
firebase init

# select hosting, functions

Now make your public folder dist/browser, but rewrite all traffic to a function.

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

Remove the Express Server Listener

When deploying to AppEngine we need to tell the server to listen to requests. In Cloud Functions, this is already happening under the hood, so we need to update our server code.

Make sure to export the express app, then remove the call to listen.

file_type_typescript server.ts
export const app = express();

// ...

// remove or comment out these lines 👇

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

Update the Webpack Config

We need to tell the Webpack to package our server code as a library that can be consumed by the Node function. Update the existing config with the following changes.

file_type_js webpack.server.config.js
  output: {
    // Puts the output at the root of the dist folder
    path: path.join(__dirname, 'dist'),
    library: 'app',
    libraryTarget: 'umd',
    filename: '[name].js',
  },

Make sure to rebuild the Angular app with npm run build:ssr.

Copy the Angular App to the Function Environment

The cloud function needs access to your Angular build in order to render it on the server. Let’s write a simple node script that copies the most recent Angular app to the functions dir on build.

command line
cd functions
npm i fs-extra
file_type_js 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);

})();

Update the build script to copy over your Angular files. While here, you can also mark this function to be deployed with Node v8.

file_type_npm functions/package.json
{
  "name": "functions",
  "engines": {
    "node": "8"
  },
  "scripts": {
    "build": "node cp-angular && tsc"
  }
}

The function itself only needs to import the universal app into the current working directory. That’s why we need to copy it to the function’s environment.

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

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

You can test it by serving both the hosting and function simultaneously - the moment of truth…

command line
cd functions
npm run build
firebase serve

You should now be able to visit your server rendered site on localhost:5000.

If it looks good, deploy the app with a single command:

command line
firebase deploy
Angular Universal cloud functions twitter card validator

Questions? Let's chat

Open Discord