Search Lessons, Code Snippets, and Videos
search by algolia
X
#native_cta# #native_desc# Sponsored by #native_company#

Server Side Rendering Firebase Angular Universal

Episode 99 written by Jeff Delaney
full courses and content on fireship.io

Update! Watch the latest SSR video and get the most up-to-date code by watching Angular Universal SSR with Cloud Functions on Fireship.io



Full source code AngularFire2 Universal Demo.

I am thrilled to finally bring you a server-side rendering (SSR) tutorial with AngularFire2. This has been a highly requested topic, but in the past there were compatibility issues between Angular Universal and the Firebase Web SDK. As of angularfire2 v5.0.0-rc.7, we are able to combine these two powerful tools into reliable Search Engine Optimization solution for Angular-Firebase apps.

Give James Daniels from Firebase some props for making this possible in AngularFire2 - it was no easy task.

angularfire2 data on twitter card validator

Updates for Angular v6.0

This article as originally released using Angular 5.2. Version 6 introduced some breaking changes to the process, so I have updated the article to cover both versions.

When to use Server Side Rendering

What is server-side rendering? It is a technique that parses your frontend app to HTML on a server (NodeJS), as opposed to the normal process of rendering on the browser.

SSR adds additional complexity and mental boilerplate to your project. Before configuring Angular Universal, ask yourself Do I really need to render on the server? There are two main use cases:

  • Search Engine Optimization (SEO)
  • Social Media Linkbot Previews

If you need your pages and deep links to be reliably indexed on search engines, SSR may be essential because most search engines do not parse complex JavaScript apps very well. They want plain HTML and that’s what SSR delivers.

If your content will be shared on social media, SSR may be essential because all link bots - Twitter, Facebook, Slack, etc - will not parse JavaScript, so only the meta tags on your index.html file will be rendered.

angularfire2 data on twitter card validator

Universal vs Rendertron

A few months ago I introduced an SSR alternative that uses Rendertron to parse pages with headless Chrome. These SEO strategies are mutually exclusive, so let’s quickly compare them.

Rendertron is…

  • easier to setup
  • significantly slower at rendering

Angular Universal is…

  • difficult to setup by comparison
  • can be highly performant
    vides more control and reliability

AngularFire2 + Angular Universal Step-by-Step

In the following section I will walk you through every step required to get up and running with Angular Universal. Yes, there are a ton of steps, but they are all relatively standardized and simple on an individual basis.

Part One - Build an App

In part one we will build an app that that generate metatags dynamically with data saved in Cloud Firestore.

Step 1 - Install Dependencies

First, install the Angular CLI

npm install @angular/cli@latest -g

Generate a new app with the CLI, making sure to include the router.

ng new awesomeApp --routing 
cd awesomeApp

Install firebase and AngularFire. Follow the official AngularFire2 install steps, but make sure you install the latest release.

npm install firebase angularfire2@next -s

And some dependencies for Angular Universal:

npm install @angular/platform-server @nguniversal/module-map-ngfactory-loader ts-loader -s

Step 2 - Create an SEO Service for MetaTags

ng g service seo -m app

The next step is to generate the title and metatags dynamically for various routes in the app. If you do this in more than one component you will likely want the logic extracted to a service. Unfortunately, Angular cannot update meta tags in bulk, so we need to call updateTag a bunch of times to update tags between route changes.

import { Injectable } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';

@Injectable()
export class SeoService {

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

generateTags(tags) {
// default values
tags = {
title: 'Angular SSR',
description: 'My SEO friendly Angular Component',
image: 'https://angularfirebase.com/images/logo.png',
slug: '',
...tags
}

// Set a title
this.titleService.setTitle(tags.title);

// Set meta tags
this.meta.updateTag({ name: 'twitter:card', content: 'summary' });
this.meta.updateTag({ name: 'twitter:site', content: '@angularfirebase' });
this.meta.updateTag({ name: 'twitter:title', content: tags.title });
this.meta.updateTag({ name: 'twitter:description', content: tags.description });
this.meta.updateTag({ name: 'twitter:image', content: tags.image });

this.meta.updateTag({ property: 'og:type', content: 'article' });
this.meta.updateTag({ property: 'og:site_name', content: 'AngularFirebase' });
this.meta.updateTag({ property: 'og:title', content: tags.title });
this.meta.updateTag({ property: 'og:description', content: tags.description });
this.meta.updateTag({ property: 'og:image', content: tags.image });
this.meta.updateTag({ property: 'og:url', content: `https://yourapp.com/${tags.slug}` });
}
}

Step 2 - Create a Routed Component

Now we need something to render. In this demo I have an individual animal detail page, which is where the SEO optimizations will take place.

ng g component animal-detail

Add the component to the app.routing.module, and give it a route param of :name. This param should be the document ID in Firestore.

const routes: Routes = [
{ path: 'animals/:name', component: AnimalDetailComponent }
];

Now we need some data to bulid these meta tags dynamically.

Firestore data for seo meta tags

In the component, we will grab the route name param to query some document in Firestore to use for the metatags.

@Component({...})
export class AnimalDetailComponent implements OnInit {
animal$: Observable<any>;

constructor(
private afs: AngularFirestore,
private seo: SeoService,
private route: ActivatedRoute,
private state: TransferState
) {}

ngOnInit() {
const id = this.route.snapshot.paramMap.get('name').toLowerCase();
this.animals$ = this.afs.doc<any>(path)
.valueChanges()
.pipe(
tap(animal => {
this.seo.generateTags({
title: animal.name,
description: animal.bio,
image: animal.imgURL
})
})
)

}

Step 2 ½ - Transfering State

Our component works, but it results in a split-second flash when the app transitions from server to browser. Why? The browser app does not know the app’s data state. So data from Firebase or any HTTP calls will be re-requested after the transfer. Fortunately, Angular Universal has a TransferState class allow the two apps to communicate.

It works by allowing you to set a key-value pair on the server, then read it after the browser transition. Because we’re dealing with an Observable, we can pipe in the startWith operator to avoid the initial null state that causes the flash.

import { tap, startWith } from 'rxjs/operators';
import { TransferState, makeStateKey } from '@angular/platform-browser';

const ANIMAL_KEY = makeStateKey<any>('animal');

// omitted ...

ngOnInit() {
const id = this.route.snapshot.paramMap.get('name').toLowerCase();
this.animal$ = this.ssrFirestoreDoc(`animals/${id}`);
}

ssrFirestoreDoc(path: string) {
const exists = this.state.get(ANIMAL_KEY, {} as any);
return this.afs.doc<any>(path).valueChanges().pipe(
tap(animal => {
this.state.set(ANIMAL_KEY, animal)
this.seo.generateTags({
title: animal.name,
description: animal.bio,
image: animal.imgURL
});
}),
startWith(exists)
)
}

And one final thing… If you’re performing state transfer, update the main.ts file to listen for DOMContentLoaded before bootstraping the browser app.

document.addEventListener('DOMContentLoaded', () => {
platformBrowserDynamic().bootstrapModule(AppModule)
})

Part Two - Angular Universal 6.x

If using Angular 6.x follow the steps outlined in the step-by-step snippet.

Alternatively, you can look into the Angular Universal Prerendering strategy.

Part Two - Angular Universal 5.x

At this point we have our app in place - now it’s server configuration time.

Step 4 - Setup the Server to Browser NgModules

Add withServerTransition to your app.module.ts.

// src/app/app.module.ts
@NgModule({
imports: [
BrowserModule.withServerTransition({ appId: 'ssr-app' }), // <-- here
]
})

Create src/app/app.server.module

import { NgModule } from '@angular/core';
import { ServerModule, ServerTransferStateModule } from '@angular/platform-server';
import { ModuleMapLoaderModule } from '@nguniversal/module-map-ngfactory-loader';

import { AppModule } from './app.module';
import { AppComponent } from './app.component';

@NgModule({
imports: [
AppModule,
ServerModule,
ServerTransferStateModule, // <-- needed for state transfer
ModuleMapLoaderModule // <-- needed for lazy-loaded routes
],
bootstrap: [AppComponent],
})
export class AppServerModule {}

Step 5 - Create the Main Server Entrypoint

Now we need the main entry point for the server rendered app. Create src/main.server.ts.

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

And give it a TS config. Notice how we’re transpiling to commonJS for Node.

Create src/tsconfig.server.json

{
"extends": "../tsconfig.json",
"compilerOptions": {
"outDir": "../out-tsc/app",
"baseUrl": "./",
"module": "commonjs",
"types": []
},
"exclude": [
"test.ts",
"**/*.spec.ts"
],
"angularCompilerOptions": {
"entryModule": "app/app.server.module#AppServerModule"
}
}

Step 6 - Add the CLI App Config

Make one small change to the existing app config, then add an additional CLI config object for the server app. angular-cli.json

"apps": 
"outDir": "dist/browser", // <-- change here
// ...
},
// copy over this server config below...
{
"platform": "server",
"root": "src",
"outDir": "dist/server",
"assets": [
"assets",
"favicon.ico"
],
"index": "index.html",
"main": "main.server.ts",
"test": "test.ts",
"tsconfig": "tsconfig.server.json",
"testTsconfig": "tsconfig.spec.json",
"prefix": "",
"serviceWorker": false,
"styles": [
"styles.scss"
],
"scripts": [],
"environmentSource": "environments/environment.ts",
"environments": {
"dev": "environments/environment.ts",
"prod": "environments/environment.prod.ts"
}
}
],

Step 7 - ExpressJS

Firebase uses several packages on the server that are not available by default. In my case, I needed to install the following packages.

npm i xmlhttprequest ws -s
npm i bufferutil utf-8-validate -s

The code below is the ExpressJS server that renders and serves the app. In a nutshell, it reads Angular’s JavaScript from the dist/server build sends a response as HTML - exactly what bots and search engines want to see.

Create server.ts in the project root.

// These are important and needed before anything else
import 'zone.js/dist/zone-node';
import 'reflect-metadata';

import { renderModuleFactory } from '@angular/platform-server';
import { enableProdMode } from '@angular/core';

import * as express from 'express';
import { join } from 'path';
import { readFileSync } from 'fs';

(global as any).WebSocket = require('ws');
(global as any).XMLHttpRequest = require('xmlhttprequest').XMLHttpRequest;


// Faster server renders w/ Prod mode (dev mode never needed)
enableProdMode();

// Express server
const app = express();

const PORT = process.env.PORT || 4000;
const DIST_FOLDER = join(process.cwd(), 'dist');

// Our index.html we'll use as our template
const template = readFileSync(join(DIST_FOLDER, 'browser', 'index.html')).toString();

// * NOTE :: leave this as require() since this file is built Dynamically from webpack
const { AppServerModuleNgFactory, LAZY_MODULE_MAP } = require('./dist/server/main.bundle');

const { provideModuleMap } = require('@nguniversal/module-map-ngfactory-loader');

app.engine('html', (_, options, callback) => {
renderModuleFactory(AppServerModuleNgFactory, {
// Our index.html
document: template,
url: options.req.url,
// DI so that we can get lazy-loading to work differently (since we need it to just instantly render it)
extraProviders: [
provideModuleMap(LAZY_MODULE_MAP)
]
}).then(html => {
callback(null, html);
});
});

app.set('view engine', 'html');
app.set('views', join(DIST_FOLDER, 'browser'));

// Server static files from /browser
app.get('*.*', express.static(join(DIST_FOLDER, 'browser')));

// All regular routes use the Universal engine
app.get('*', (req, res) => {
res.render(join(DIST_FOLDER, 'browser', 'index.html'), { req });
});

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

That’s a lot of code, but it is the standard universal example. There are only a few unique lines for Firebase, which prevent web sockets and XMLHttpRequest from throwing errors in Node.

(global as any).WebSocket = require('ws');
(global as any).XMLHttpRequest = require('xmlhttprequest').XMLHttpRequest;

Step 8 - Webpack Config

npm i [email protected] -D
npm i [email protected] -D

The Angular CLI doesn’t know how to deal with our Express server code, but we can leverage Webpack to transpile TypeScript into the dist folder. In my experience, I had to set the webpack and ts-loader versions to those listed in the commands above (but that may not be necessary in your case).

Create webpack.server.config.js in the project root.

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

module.exports = {
entry: { server: './server.ts' },
resolve: { extensions: ['.js', '.ts'] },
mode: 'development',
target: 'node',
externals: [/(node_modules|main\..*\.js)/],
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].js'
},
module: {
rules: [
{ test: /\.ts$/, loader: 'ts-loader' }
]
},
plugins: [
new webpack.ContextReplacementPlugin(
/(.+)?angular(\\|\/)core(.+)?/,
path.join(__dirname, 'src'), // location of your src
{} // a map of your routes
),
new webpack.ContextReplacementPlugin(
/(.+)?express(\\|\/)(.+)?/,
path.join(__dirname, 'src'),
{}
)
]
}

Step 9 - Build scripts

Let’s now add a few NPM scripts to compile our build efficiently from the command line.

Add these scripts to package.json

"scripts": {
// ... omitted
"start": "node dist/server",
"build:ssr": "npm run build:client-and-server-bundles && npm run webpack:server",
"serve:ssr": "node dist/server.js",
"build:client-and-server-bundles": "ng build --prod && ng build --prod --app 1 --output-hashing=false",
"webpack:server": "webpack --config webpack.server.config.js --progress --colors"
},

Now test it out.

npm run build:ssr
npm run serve:ssr

You should see your SSR app available on localhost:4000.

Step 10 - Deploy to your Server

Now we need a NodeJS server to host our site. Since we’re already using Firebase, we can easily deploy to App Engine (but keep in mind that you pay by the hour). In a future lesson, I will show you how to setup Angular Universal on Firebase Cloud Functions to avoid these costs.

Create an app.yaml in the project root.

runtime: nodejs
env: flex
skip_files:
- src/
- node_modules/
- e2e


manual_scaling:
instances: 1
resources:
cpu: 1
memory_gb: 0.5
disk_size_gb: 10

You can easily deploy the Node app to App Engine, assuming you have Google Cloud CLI tools installed.

gcloud app deploy

The End

Congrats, we have solved one of the most challenging feats in Angular Firebase development. Server-side rendering provides a reliable strategy for search engine optimization and linkbot-friendliness, and maybe even some PWA performance gains. Reach out on Slack or in the comments if you run into issues.