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

Angular Universal SSR Prerendering With Firebase Hosting

Episode 106 written by Jeff Delaney
full courses for pro members



Do you want the SEO and performance benefits of server-side rendering (SSR), but without the need to deploy, maintain, and pay for a NodeJS server? This lesson shows you how to use Angular Universal prerendering to accomplish this exact goal.

Prerendering is the process of selectively server-side rendering specific routes in Angular, then writing an index.html to the production build for each route. It is very similar conceptually to Gatsby for React and VuePress. On the initial load, this makes your app behave like a static website (as opposed to a single page app). Search engines and bots get the fully rendered HTML very quickly, while actual users are transitioned to a fully-interactive Angular app.

Your deployed file structure will look like the screenshot below. Notice how there is a full multi-page file structure that allows users to land on any prerendered route. Normally, an Angular app is an SPA with a single index.html entry point.

The file structure of a prerendered Angular app

Also, initial page load performance is amazing with this technique because (1) your content has zero rendering delays at runtime and (2) it is cached in a global CDN by Firebase hosting.

Performance of prerendered page on chrome lighthouse

See Universal Prerending in action at the Firestarter Demo App.

Prerendering vs Server-Side Rendering vs Rendertron

At this point, I have covered three distinct strategies for server rendered content in Angular that can make your app SEO and linkbot friendy. Let’s breakdown the strengths and weaknesses of each approach.

Goal SSR Prerendering Rendertron
performance fast fastest slow
hosting cost ~$30/m $0 ~$30/m
complexity highest lowest low
static hosting No Yes No
scale limitations Unlimited Yes Unlimited
content rendered at request-time build-time request-time
deployed to AppEngine Flex firebase hosting AppEngine + Cloud Functions

Prerendering is ideal for sites that have static content that changes infrequently, say an About Us page or Product Listings. Each time you deploy your app, the selected routes are rendered. The main drawback is that this content will not get updated (for bots) until the next time you deploy. End users are transitioned to Angular, so they always get the freshest content.

Initial Setup

I will be demonstrating the universal prerendering technique on the Firestarter Demo App. Our goal is to prerender this list of animals from pulled from the Cloud Firestore DB.

The prerendered feed of animals on static Firebase hosting

The following steps assume you have an existing Angular v6+ project started with AngularFire2 installed (note that AngularFire2 is optional). Then generate the Universal boilerplate with the CLI.

ng g universal --client-project firestarter

This command creates several files for us required for Universal SSR.

Handling State and Lazy Loading

If you don’t need lazy-loading support or state transfer, then you could technically skip this step. However, most apps will need this functionality, so I recommend configuring these parts anyway.

If your app wants to use lazy-loaded modules and/or transfer state from server to browser, then we need to make a few updates to our NgModules.

npm i @nguniversal/module-map-ngfactory-loader --save
// src/app/app.module.ts
import { BrowserTransferStateModule } from '@angular/platform-browser';

@NgModule({
imports: [
// ...
BrowserModule.withServerTransition({ appId: 'firestarter' }),
BrowserTransferStateModule
]
})

Update the src/app/app.server.module with the following code:

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


@NgModule({
imports: [
// ...
ServerTransferStateModule, // <-- needed for state transfer
ModuleMapLoaderModule // <-- needed for lazy-loaded routes
],
})

Add a Component

In this section, I am adding a component to the app that (1) is loaded by the router, (2) creates dynamic content and meta-tags from the Firestore database. Our goal is to make this page’s dynamic content available to link-bots and search engines.

ng g component ssr-page

The component will fetch some data from our database during ngOnInit and set metatags for a Twitter Card. It will also check to see if the data has already been

import { Component, OnInit } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
import { AngularFirestore } from 'angularfire2/firestore';

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

// The state saved from the server render
const DATA = makeStateKey<any>('animals');

@Component({
selector: 'ssr-page',
templateUrl: './ssr-page.component.html',
styleUrls: ['./ssr-page.component.scss']
})
export class SsrPageComponent implements OnInit {

animals$;

constructor(
private afs: AngularFirestore,
private meta: Meta,
private titleService: Title,
private state: TransferState
) { }

ngOnInit() {
// set metatags for twitter
this.setMetaTags();

// If state is available, start with it your observable
const exists = this.state.get(DATA, [] as any);

// Get the animals from the database
this.animals$ = this.afs.collection('animals').valueChanges()

.pipe(
tap(list => {
this.state.set(DATA, list);
}),
startWith(exists)
)
}

setMetaTags() {
this.titleService.setTitle('Angular Firebase Animals');

// Set meta tags
this.meta.updateTag({ name: 'twitter:card', content: 'summary' });
this.meta.updateTag({ name: 'twitter:site', content: '@angularfirebase' });
// ... and so on
}

}

Loop over the animals in the HTML

<div *ngFor="let animal of animals$ | async">
{{ animal | json }}
</div>

And lastly, let’s make sure to add this component to the app.routing.module.

import { SsrPageComponent } from './ui/ssr-page/ssr-page.component';

const routes: Routes = [
// ...
{ path: 'ssr', component: SsrPageComponent },
];

Prerendering with Universal

At this point, we could either or use Angular Universal for runtime SSR with NodeJS or build-time prerendering. The overall process is quite similar for both, but instead of using a NodeJS server to generate HTML at runtime, we create a script to render the pages at build-time - just like a plain old static website.

Prerendering Script

This section is where all of the Universal prerendering magic happens. First, we have a few dependencies for development.

npm i -D webpack-cli fs-extra http-server ts-loader

# for Firebase
npm i -D ws xmlhttprequest

The prerendering script will loop over each of the specified routes, render the html with Angular Universal, then write the results to the appropriate location in the dist folder.


import 'zone.js/dist/zone-node';
import 'reflect-metadata';
import { renderModuleFactory } from '@angular/platform-server';

async function prerender() {
// pro only
}




// Load zone.js for the server.
import 'zone.js/dist/zone-node';
import 'reflect-metadata';

import { join, resolve } from 'path';
(global as any).WebSocket = require('ws');
(global as any).XMLHttpRequest = require('xmlhttprequest').XMLHttpRequest;

import { enableProdMode } from '@angular/core';

// Import module map for lazy loading
import { provideModuleMap } from '@nguniversal/module-map-ngfactory-loader';
import { renderModuleFactory } from '@angular/platform-server';

import * as fs from 'fs-extra';

// Add routes manually that you need rendered
const ROUTES = [
'/',
'/ssr',
'/login',
'/some/deep/route'
];

const APP_NAME = 'firestarter';

// leave this as require(), imported via webpack
const {
AppServerModuleNgFactory,
LAZY_MODULE_MAP
} = require(`./dist/${APP_NAME}-server/main`);

enableProdMode();


async function prerender() {
// Get the app index
const browserBuild = `dist/${APP_NAME}`;
const index = await fs.readFile(join(browserBuild, 'index.html'), 'utf8');


// Loop over each route
for (const route of ROUTES) {
const pageDir = join(browserBuild, route);
await fs.ensureDir(pageDir);

// Render with Universal
const html = await renderModuleFactory(AppServerModuleNgFactory, {
document: index,
url: route,
extraProviders: [provideModuleMap(LAZY_MODULE_MAP)]
});

await fs.writeFile(join(pageDir, 'index.html'), html);
}

console.log('done rendering :)');
process.exit();
}

prerender();


Webpack Config

Lastly, we need to compile the prerendering script with Webpack. Create a new file called webpack.prerender.config.js in the project root.

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

const APP_NAME = 'firestarter';

module.exports = {
entry: { prerender: './prerender.ts' },
resolve: { extensions: ['.js', '.ts'] },
mode: 'development',
target: 'node',
externals: [/(node_modules|main\..*\.js)/],
output: {
path: path.join(__dirname, `dist/${APP_NAME}`),
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'),
{}
)
]
}

Build, Serve, and Deploy

Almost done! We just need to run a couple commands that will compile and serve our code.

Build Scripts

When building for production, we have four steps in the build process

  1. Build the browser app
  2. Build the server app
  3. Build the universal prerendering script
  4. Run the script to prerender the routes

Let’s organize these commands in our package.json scripts.

"scripts": {
// ... omitted
"webpack:prerender": "webpack --config webpack.prerender.config.js",
"build:prerender": "node dist/YOUR_PROJECT_NAME/prerender.js",
"serve:prerender": "http-server dist/YOUR_PROJECT_NAME -o",
"build:all": "ng build --prod && ng run YOUR_PROJECT_NAME:server && npm run webpack:prerender && npm run build:prerender"
},

Now run all four steps in a single command.

npm run build:all

Serving the App Locally

Our production-ready prerendered app is build, we just need a way to test it out. The easiest way is to spin up an http-server and point it dist/your-app.

http-server ./dist/firestarter

Deployment to Firebase Hosting

All of our static files have been rendered and we are ready to deploy to Firebase hosting. In fact, you can deploy this app to any static host, such as an Amazon S3 bucket, Zeit, etc.

Setup Firebase Hosting for Server-Side Rendered Apps

Make sure you have Firebase Tools installed on your system, then initialize hosting.

There is one really important step when initializing firebase hosting - make sure to select NO when prompted to configure for a single page application

firebase init hosting

# folder - dist/firestarter
# single page app - NO

Deploy

At this point, deployment is a piece of cake.

firebase deploy

The final angular universal ssr app deployed to firebase hosting

You should be able to test your performance out with the Chrome lighthouse plugin.

The End

Prerendering is a awesome strategy for apps with a limited number of landing pages that need to be search engine optimized and link-bot friendly. This method can scale up to thousands of pages, but just keep in mind that your content only updates on a deploy, so it might not be the best choice for sites where data changes frequently.