Luciano Serruya Aloisi

Software developer from Trelew, Chubut, Argentina 🇦🇷

Angular Universal: what they don't tell you

ng add @nguniversal/express-engine and we are good to go?. Technically, yes, but you should keep reading

What is Angular Universal anyway?

Angular Universal is Angular's official library for rendering your Angular app on the server. Installing it with the previous command scaffolds an express server with proper setup to render your app. Because you now have an express server, you can easily add API routes or serve your static files for example, and you don't even have to worry about CORS! (your front-end app is being served from the same origin your back-end is). In other words, Angular Universal is the way to go in Angular if you want to implement SSR (Server-Side Rendering). Although it is the official library, there is a strange lack of official documentation about it (the whole Angular Universal docs in the angular.io site is only one-page long); luckily enough Angular University has an amazing blog post with all you need to know about this library

Now that you have your Angular Universal app ready to go, you can run your development server doing npm run dev:ssr. So, if my app is rendered in the server, what about event handlers? Having an SSR app means that it will be rendered in the server, and the resulting markup will be sent to the client, but once in the browser it will be executed once again, so your client-side JavaScript code is booted (I recommend reading this amazing article about rendering content on the web) - this process of executing your code once again is known as rehydration.

The thing is, you most probably are fetching your data in your component via a service, right? For example, if you were building a blog, you're probably calling methods of your PostService class. That's great, you are following Angular's guidelines (and you should definitively keep doing that now that you have an Angular Universal app). You're fetching your data in your component via your service, your component is rendered in the server, and once it's loaded on the user's browser, it will execute once again your component's code, so fetching your data a second time.

What can we do about this issue? We don't want to duplicate our requests to our back-end. Luckily enough, Angular provides us with some handy functions to detect what platform are we currently running our code on

import { Component, Inject, PLATFORM_ID } from "@angular/core";
import { isPlatformBrowser } from "@angular/common";

@Component({...})
export class ... {
  constructor(..., @Inject(PLATFORM_ID) private platformId: Object) {}

  someMethod() {
    if(isPlatformServer(this.platformId) {
      console.log("This code will only execute on the server");
    }
  }
}

Besides isPlatformServer, Angular also provides other functions such as isPlatformBrowser, isPlatformWorkerApp, and isPlatformWorkerUI.

This certainly does not look like a viable solution, as we would have to update all of our components that fetch data via services. The alternative would be to move this logic of only fetching data in the server/client to desired services.

Let's suppose we only fetch data in the client. This would work okay, and would solve our problem of fetching data twice. But about SEO? Didn't we move to a SSR application for SEO matters? We would be in the same place as before. Fetching data in the client would also mean that we would need to display some sort of progress bar or spinner to indicate that the app is loading.

Okay so, let's go the other way around, we'll fetch data only in the server. After you implemented that, you realized you don't see any data in your app. That's weird, because if you inspect the HTML returned by the server, data is there, but when you try to see it in the browser, it is not. Strangely enough, when your app now loads, it flashes some data, and then it disappears!

So what's going on here? Let's analyze a little bit further what's happening. Once again, data is fetched and components are rendered in the server, and resulting markup is sent back to the client's browser. Once in the browser, components' code will execute again, and data fetched a second time. Unfortunately, Angular does not preserve state between executions in different platforms, so that's why we first see a flash of data, and then a blank screen (the application loses its state it came from the server when it's executed a second time).

Having said that, we now need some mechanism to fetch initial data only in the server, and preserve it once it hits the browser. For that matter, Angular provides us with a class called TransferState. This class allows us to cache our data in a key/value store and retrieve it later

export class PostService {
  constructor(..., private transferState: TransferState) {}

  getPosts() {
    const GET_POSTS_KEY = makeStateKey<Post[]>("GET_POSTS_KEY");
    if (this.transferState.hasKey(GET_POSTS_KEY)) {
      const posts = this.transferState.get(GET_POSTS_KEY, [])
      return of(posts);
    }
    return this.http.get(...).pipe(
      tap(posts => {
        this.transferState.set(GET_POSTS_KEY, posts);
      })
    );
  }
}

The previous snippet shows the usual flow you would implement using TransferState

  1. Check if data is already available, and return it as an Observable if it is
  2. If it is not available, fetch it and cache it using TransferState

As your app's code will first be executed in the server, data will not be available, so it will make the HTTP request (following the previous example) and store it using TransferState. Then, when your app is booted in the browser, it will check if it has data available, which will have, and then return it without making any further request.

It is also pretty important to note that TransferState will serialize your data, and store it in a script tag inside your page, so be sure that your data objects are serializable

In order to use TransferState, you first need to import it in your app's modules. As you will be using it in both the browser and server, you need to import in in both app.module.ts and app.server.module.ts

// app.module.ts

import { ..., BrowserTransferStateModule } from "@angular/platform-browser";

.
.
.

@NgModule({
  ...,
  imports: [
    ...,
    BrowserTransferStateModule
  ]
});
export class AppModule { ... }
// app.server.module.ts

import { ..., ServerTransferStateModule } from "@angular/platform-server";

.
.
.

@NgModule({
  ...,
  imports: [
    ...,
    ServerTransferStateModule
  ]
});
export class AppServerModule { ... }

To further read about TransferState, I recommend you taking a look at this article by Philippe Martin

Note for Firebase users

If you are using Firebase for its real-time capabilities and you are thinking about migrating your Angular SPA to Angular Universal, bare in mind that you will have to rethink how your application consumes data in real time. You won't be able to receive any updates if you cache your data using TransferState. If you still want to update your UI automatically when Firebase pushes a new snapshot of your data, you should only set your listeners on the browser - it won't make any sense setting real-time listeners in the server as execution will end once your app is rendered and ready to be sent to the browser

Native APIs

Be aware that your app now first runs on a Node.js environment, so browser APIs won't be available. In case you were accessing document or window directly, you will now need to check if your app is currently running in the browser using the isPlatformBrowser function. This document from the Angular Universal repo explains things a little bit in more detail and with some examples of how to avoid running into bugs.

Deployment

You don't have a SPA anymore, so you can't rely on your static file hosting service any longer. You now need some server environment to deploy and serve your app. Fortunately enough, when you installed Angular Universal using express starter, it already set up a function called app in the server.ts file that returns a express server, so you can pass any incoming HTTP request from a serverless function to that express server, for example. You could also deploy it to a VPS or to Cloud Run or ECS dockerizing your app as well.

If you are deploying your Angular Universal app using Cloud Functions, be extremely careful with the following. Some tutorials on how to deploy your Angular Universal app to Cloud Functions are outdated, such as this one from AngularFire docs and this one from Fireship. Angular Universal starter introduced a subtle change but can introduce some tricky bugs. In previous versions, Angular-Universal-express starter used to create a server.ts file which exported an express server called app, so you could import this app object directly in your Cloud Functions code and pass it the incoming req and res objects. Latest versions of Angular-Universal-express starter now export an app function in your server.ts file, which receives no arguments and returns an express server, so in your Cloud Functions code instead of doing this

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

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

you would do this

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

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

Note that we are now calling app by doing app(). Pretty tricky bug, although easy to fix.

In case you had your Angular SPA deployed via Firebase Hosting, you can still use Firebase Hosting to proxy any requests to your SSR app hosted in a Cloud Function. In your firebase.json file, add a rewrite rule to pass any traffic to your Cloud Function, like so

"hosting": {
  "rewrites": [{
   "source": "**",
   "function": "ssr", 
  }]
}

(assuming your Cloud Function that serves your Angular Universal app is named ssr. Change that name to whatever you named your Cloud Function).

There is a pretty significant caveat with Firebase Hosting. If you were serving your app using Firebase Hosting, you most definitively were uploading your static files using the firebase CLI. Now, our app is not static, is rendered by our ssr Cloud Function, but Firebase Hosting gives a higher priority to static files in the public directory that you uploaded.

Let's now suppose that your app's index page (/) is server-side rendered, but you are still uploading to Firebase Hosting a static file called index.html - this static file will be served by Firebase Hosting, instead of reaching out to your Cloud Function to render your app. A workaround for this issue would be to create an empty directory (called empty-dist, for example), and uploading that directory to Firebase Hosting, so every incoming request is always passed to your Cloud Function. You firebase.json file should look like this

"hosting": {
  "public": "empty-dist",
  "rewrites": [{
   "source": "**",
   "function": "ssr", 
  }]
}

Conclusion

Angular is a great framework and it won't go away any time soon. In case you need to server-side render your app, Angular Universal is the go-to library - it comes with its caveats though.

Firebase is a pretty popular product suite that is used in many Angular apps, so it is to expect that it will still be used to host Angular Universal apps, but not without previously sort some issues out.

Hope you liked it!

🐦 @LucianoSerruya

📧 lucianoserruya (at) gmail (dot) com