Server Side Rendering With Angular & Firebase

5 May 2018By Rich @Black Sand Solutions
  • Angular
  • SSR
  • Firebase
Photo by imgix on Unsplash

A step by step guide on how to set up server side rendering (SSR) with Angular 5 and Firebase hosting

Server Side Rendering With Angular & Firebase

There's several guides on the web about how to do this, however none of them worked for me. Most of these issues were due to differences in my environment and the guides - Node, Angular, Angular CLI, Firebase etc. That said, they all contributed to my arriving at this solution.

Note: I'm going to assume you already know what Server Side Rendering is and what the benefits are.

Environment

This is the environment used for this solution.

  • Windows 10
  • node 6.11.5
  • npm 5.5.1
  • angular 5.2.0
  • angular CLI 1.7.3
  • firebase tools 5.5.1
  • typescript 2.8.3

Note: I purposefully fixed my Angular CLI version, as the latest version of the CLI installs Anguaalr 6 and RxJS & I wanted to keep the number of changes to a minimum. Note: Normally I use Node 8.9.1 but this is incompatible with the firebase-functions emulator. I therefore installed nvm-windows which allows me to run multiple versions of Node on my machine.

PreRequisites

Ensure that you have firebase tools and the AngularCLI installed; ideally at the versions above.

NVM (If Required)

  • Install nvm-windows from here.
  • Install nvm 6.11.5 (cannot run firebase functions emulator with later versions) nvm install 6.11.5

Create A New Project

  • Use Ng cli (1.7.3) to create a new project ng new <project>
  • ng serve to check it actually runs!

At this point you should have the default angular seed project example running in your browser.

Set Up Firebase Hosting

  • Set up firebase hosting (windows cmd) firebase init hosting

    • choose a project
    • set the public dir as dist
    • rewrite all urls to index (Y)

Test Deployment

  • build app for deployment ng build --prod - creates dist folder
  • test deploy firebase deploy
  • check the site at hosting url: https://<project-id>.firebaseapp.com
  • view source - should see <app-root></app-root> and no other angular created content as this is NOT SSR.

Set Up Firebase

Now we are going to set up firebase hosting and functions. Hosting will be used to host the production application and functions will be used to create the server side version.

Set Up Firebase Functions

  • set up firebase functions firebase init functions

    • use existing project (skip)
    • don't lint (let's introduce the minimum changes right now)
    • use javascript (let's introduce the minimum changes right now)
    • install depedendencies

Test Functions

  • uncomment the hello world example and save the file
  • start the emulator firebase serve --only functions
  • open url in browser: http://localhost:5000/<project-id>/us-central1/helloWorld
  • confirm you see Hello World
  • stop emulator

Great, functions are set up and working. Let's move on.

Test Hosting & Functions

Why? Because I have had issues with this working before and I want to know each step works. This helps narrow down the location of bugs

  • firebase serve

You should see the application that was built to the dist folder served at http://localhost:5000/

And the functions should be served at: http://localhost:5000/<project-id>/us-central1/helloWorld

Start SSR Setup

When our app is loaded, we want to render the initial page server side and return that instead of the default index normally served.

Modify Angular App

In order to build the server side applicaiton we need to install @angular/platform-server.

Ensure the version matches the rest of your angular dependencies.

  • npm install @angular/platform-server@5.2.0

update app.module

Open app.module.ts and modify the imports like so:

  imports: [
    BrowserModule.withServerTransition({ appId: 'ssrapp' }),
  ],

app.server.module

Create a new file src/app/app.server.module.ts. This is an ng module for the server; it tells the server what app to build. The universal bundle will be created from this module.

import { NgModule } from '@angular/core';
import { ServerModule } from '@angular/platform-server';
import { AppModule } from './app.module';
import { AppComponent } from './app.component';
@NgModule({
    imports: [
        ServerModule,
        AppModule
    ],
    bootstrap: [AppComponent]
})
export class AppServerModule { }

main-ssr.ts

Create a new file src/main-ssr.ts. This defines how we export the app module

export { AppServerModule } from './app/app.server.module';

main-ssr ts config

In order to convert the typescript file above we need a matching ts config. This will extend existing config used by the client side. An important change is that we use commonjs module, since that is what node js uses.

  • Create new file src/tsconfig.server.json
  • add the content
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    "outDir": "../out-tsr/app",
    "baseUrl": ".",
    "module": "commonjs",
    "types": []
  },
  "exclude": [
    "test.ts",
    "**/*.spec.ts"
  ],
  "angularCompilerOptions": {
    "entryModule": "app/app.server.module#AppServerModule"
  }
}

Tell Angular CLI how to build server app

Open the .angular-cli.json. This fle contains an array of applications. Currently there is only one (the client side app). We are going to add another one for the server side app. Add this entry to the array.

{
      "platform": "server",
      "root": "src",
      "outDir": "functions/dist-server",
      "assets": [
        "assets",
        "favicon.ico"
      ],
      "index": "index.html",
      "main": "main-ssr.ts",
      "test": "test.ts",
      "tsconfig": "tsconfig.ssr.json",
      "testTsconfig": "tsconfig.spec.json",
      "prefix": "app",
      "styles": [
        "styles.css"
      ],
      "scripts": [],
      "environmentSource": "environments/environment.ts",
      "environments": {
        "dev": "environments/environment.ts",
        "prod": "environments/environment.prod.ts"
      }
    }

Build both versions of the app

Build both versions of the application.

Browser

  • ng build --prod (this creates content in dist folder)

Server (this creates content in dist-server folder)

  • ng build -aot -app ssr --output-hashing none

Note: we use --output-hashing none since these hashes are used for client side caching and are not needed server side. It also means we don't need to keep track of the hashid when referencing the main.bundle in later steps.

Create function to render initial page

We need to ensure that the server side app has the same dependencies as the browser app.

  • update functions/package.json, by adding the following:
    "@angular/animations": "^5.2.0",
    "@angular/common": "^5.2.0",
    "@angular/compiler": "^5.2.0",
    "@angular/core": "^5.2.0",
    "@angular/forms": "^5.2.0",
    "@angular/http": "^5.2.0",
    "@angular/platform-browser": "^5.2.0",
    "@angular/platform-browser-dynamic": "^5.2.0",
    "@angular/router": "^5.2.0",
    "core-js": "^2.4.1",
    "rxjs": "^5.5.6",
    "zone.js": "^0.8.19"
  • cd into functions and npm install (or npm --prefix functions install)

Create Express server

This express server is going to handle all requests and return our server side rendered application.

Copy the following into functions/index.js

require('zone.js/dist/zone-node');
const functions = require('firebase-functions');
const express = require('express');
const path = require('path')

// Import renderModuleFactory from @angular/platform-server.
const renderModuleFactory = require('@angular/platform-server').renderModuleFactory;

// Import the AOT compiled factory for your AppServerModule.
const AppServerModuleNgFactory = require('./dist/main.bundle').AppServerModuleNgFactory;

// Load the index.html file.
const index = require('fs').readFileSync(path.resolve(__dirname, './dist-server/index.html'), 'utf8');

let app = express();

app.get('**', function(req, res) {
  renderModuleFactory(AppServerModuleNgFactory, {document: index, url: req.path})
      .then(function(html) {
        // TODO caching
         res.send(html);
      }).catch( function(e) {
         console.log(e)
      });
});

exports.ssr = functions.https.onRequest(app);

Rewrite requests for the index to functions

We need to ensure that all requests get redirected to our function., which we called ssr. firebase.json

{
  "hosting": {
    "public": "dist",
    "ignore": [
      "firebase.json",
      "**/.*",
      "**/node_modules/**"
    ],
    "rewrites": [
      {
        "source": "**",
        "function": "ssr"
      }
    ]
  }
}
  • copy dist folder into to dist-server
  • delete dist/index.html - if we don't delete this firebase hosting serves this intead of calling or SSR function.

Test Locally

firebase serve

At this point, if you open up the hosting URL and inspect the source. You should see that the <app-root> element now contains HTML content, something like below.

<app-root _nghost-c0="" ng-version="5.2.10">
<div _ngcontent-c0="" style="text-align:center">
  <h1 _ngcontent-c0="">
    Welcome to app!
  </h1>
  <img _ngcontent-c0="" alt="Angular Logo" src="data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNTAgMjUwIj4KICAgIDxwYXRoIGZpbGw9IiNERDAwMzEiIGQ9Ik0xMjUgMzBMMzEuOSA2My4ybDE0LjIgMTIzLjFMMTI1IDIzMGw3OC45LTQzLjcgMTQuMi0xMjMuMXoiIC8+CiAgICA8cGF0aCBmaWxsPSIjQzMwMDJGIiBkPSJNMTI1IDMwdjIyLjItLjFWMjMwbDc4LjktNDMuNyAxNC4yLTEyMy4xTDEyNSAzMHoiIC8+CiAgICA8cGF0aCAgZmlsbD0iI0ZGRkZGRiIgZD0iTTEyNSA1Mi4xTDY2LjggMTgyLjZoMjEuN2wxMS43LTI5LjJoNDkuNGwxMS43IDI5LjJIMTgzTDEyNSA1Mi4xem0xNyA4My4zaC0zNGwxNy00MC45IDE3IDQwLjl6IiAvPgogIDwvc3ZnPg==" width="300">
</div>
<h2 _ngcontent-c0="">Here are some links to help you start: </h2>
<ul _ngcontent-c0="">
  <li _ngcontent-c0="">
    <h2 _ngcontent-c0=""><a _ngcontent-c0="" href="https://angular.io/tutorial" rel="noopener" target="_blank">Tour of Heroes</a></h2>
  </li>
  <li _ngcontent-c0="">
    <h2 _ngcontent-c0=""><a _ngcontent-c0="" href="https://github.com/angular/angular-cli/wiki" rel="noopener" target="_blank">CLI Documentation</a></h2>
  </li>
  <li _ngcontent-c0="">
    <h2 _ngcontent-c0=""><a _ngcontent-c0="" href="https://blog.angular.io/" rel="noopener" target="_blank">Angular blog</a></h2>
  </li>
</ul>

</app-root>

Awesome, SSR in action!

Deploy to Firebase

This is a one liner: firebase deploy.

Write a script to automate

We don't want to have to copy and paste and delete files manually each time we build. Let's automate!

Create a file called build.js.

Paste in the following:

const helpers = require('./build.helpers.js');

helpers.copyFolderRecursiveSync('./dist', './functions')
helpers.removeFile('./dist/index.html');

This file defines the work we are going to do on each build

  • copy the dist folder to functions
  • delete the index file

Now create the file ./build.helpers.js And paste in the following; this will do the actual work.

const fs = require('fs');
const path = require('path');

function copyFileSync( source, target ) {

    var targetFile = target;

    //if target is a directory a new file with the same name will be created
    if ( fs.existsSync( target ) ) {
        if ( fs.lstatSync( target ).isDirectory() ) {
            targetFile = path.join( target, path.basename( source ) );
        }
    }

    fs.writeFileSync(targetFile, fs.readFileSync(source));
}

function copyFolderRecursiveSync( source, target ) {
    var files = [];

    //check if folder needs to be created or integrated
    var targetFolder = path.join(target, path.basename( source ) );
    if ( !fs.existsSync( targetFolder ) ) {
        fs.mkdirSync( targetFolder );
    }

    //copy all files & folders in directory recursively
    if ( fs.lstatSync( source ).isDirectory() ) {
        files = fs.readdirSync( source );
        files.forEach( function ( file ) {
            var curSource = path.join( source, file );
            if ( fs.lstatSync( curSource ).isDirectory() ) {
                copyFolderRecursiveSync( curSource, targetFolder );
            } else {
                copyFileSync( curSource, targetFolder );
            }
        } );
    }
}

function removeFile(target) {
    //remove file IF it exists
   const checkFileExists = s => new Promise(r=>fs.access(s, fs.F_OK, e => r(!e)))
   checkFileExists(target)
    .then(bool => bool && fs.unlinkSync(target))
}

exports.copyFolderRecursiveSync = copyFolderRecursiveSync;
exports.removeFile = removeFile;

Call the build script post build

Update package.json

   "build": "ng build --prod && ng build -prod -app ssr --output-hashing none && node build.js",

Test it out. npm run build should build both apps and perform the required copy and delete.

Add a deploy script

Update package.json While we are at it, let's add a deploy script too.

"deploy": "ng build --prod && ng build -prod -app ssr --output-hashing none && node build.js && firebase deploy"

Test it out. npm run deploy should build both apps and perform the required copy and delete AND deploy to firebase.

Gotchas

when running firebase serve

Error: No NgModule metadata found for 'AppModule'.

Error: No NgModule metadata found for 'function (){}'

Make sure you have specified the correct entry point in angular-cli.json "main": "main-ssr.ts" And not "main": "main.ts"

Error: Unable to authorize access to project

  • Delete firebae.json and firebaserc and firebase init

Repo

I'll post a repo up shortly

All Posts