Angular Universal, server-side rendering for Angular apps
Introduction
Angular universal provides tools to run an Angular application server-side in order to overcome the main weaknesses associated with Single Page Applications (SPA) built with client-side JavaScript frameworks:
- Search Engine Optimization (SEO)
Even if some search engines, such as google, have increased indexation for SPA, the fact is that a search engine cannot determine when the JavaScript framework completes page rendering. As a result, a search engine can only see a small part of the HTML and index a little part of the content.
- Social networks preview
Today, social networks are very important and most of them, such as Facebook, provide a page preview based on plain HTML to share content.
If you share a page built with a SPA you may get to obtain a bad result like bellow…
You don’t want to share your application like this!!!
- High loading time on the first page
It’s true, the loading time on the first page is higher on SPAs than on the server-side sites because SPAs are client-side applications and you have to load many resources from the server before you can see the first page. And this is all the more true if you are using a bad network or a weak device.
That’s why many SPAs show a spinner or a loading message during the first access. But it’s not very good for the user experience.
Server-side to client-side transition
If we want to keep the best of both worlds, user experience and high performance and of SPA’s combined with SEO and plain HTML of static pages, we have to use server-side rendering to serve the first page and then make a transition to client-side to use SPA as usual.
That’s what Universal does! It provides a middleware to serve an Angular app on a node.js server and after the first load make a transition to the client-side.
Let’s look how it works:
- An HTTP GET request is sent to the node.js server
- The server generates the page containing the rendered HTML and some inline JavaScript used for preboot
- The browser receives the initial playload from the server
- The user sees the HTML rendered by the server
- The preboot phase creates a hidden div that will be used to initialize the client and start recording events
- The browser makes asynchronous requests to get additional assets (i.e. images, JS, CSS, etc.)
- Once external resources are fully loaded, the bootstrap of the Angular client can begin
- The client view is rendered inside the hidden div created by the preboot phase
- The bootstrap phase is now completed
- Preboot events are replayed in order to adjust the application state to reflect changes made by the user before Angular bootstrapped (i.e. typing in textbox, clicking button, etc.)
- The preboot replaces the visible server view div by the hidden client view div
- Finally, the preboot performs some cleanup on the now visible client view including setting focus
My first Angular Universal application
The best way to build an Angular Universal application is to use the angular universal starter app.
You will also need a node.js server with NPM.
Installation
Go into your app root folder and run:
> npm install
Now you have to build your application to activate Server Side Rendering (SSR).
The project provides commands to do this, just run:
> npm run build:ssr
OK, at this point we have generated some files in the dist folder:
- In
dist/browser
repository we have our Angular application:
This app is built using src/main.ts
and src/app/app.module.ts
, you can configure you application with those files.
- In
dist/server
we have our node Express server:
This app is built using src/main.server.ts
and src/app/app.server.module.ts
. You can see in src/app/app.server.module.ts
that the server application embeds the browser application by importing the app.module.ts to run it server side.
You also have a server.ts
file, it’s the node.js Express server. You can configure the node server here (change the port, the root folder, add some plugins…)
Now we can launch the node server:
> npm run serve:ssr
You can see this in your console:
> ng-universal-demo@0.0.0 serve:ssr /universal-starter-master
> node dist/server
Node Express server listening on http://localhost:4000
Visit http://localhost:4000, you’ll see that content is immediatly served. Check the HTML source code which is now complete, you can see “hello”!!! You can now browse the application from the client like any classic SPA page.
That’s awesome… or not, it’s just a dumb hello page, let’s add some functionalities.
Add a REST call
We’ll add a REST call to the adress data gouv API in order to see how it work with universal.
In src/app/app.module.ts
add the HttpModuleClient dependency:
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { HomeComponent } from './home/home.component';
import {HttpClientModule} from "@angular/common/http";
@NgModule({
declarations: [
AppComponent,
HomeComponent,
],
imports: [
HttpClientModule,
BrowserModule.withServerTransition({appId: 'my-app'}),
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full'},
{ path: 'lazy', loadChildren: './lazy/lazy.module#LazyModule'},
{ path: 'lazy/nested', loadChildren: './lazy/lazy.module#LazyModule'}
])
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
In src/app/home/home/component.ts
add a call to the API and display the result:
import { Component, OnInit } from '@angular/core';
import {HttpClient} from "@angular/common/http";
@Component({
selector: 'home',
template: `
<h3>{{ message }}</h3>
<ul>
<li *ngFor="let feature of result.features">
{{ feature.properties.city }} - {{ feature.properties.label }} - {{ feature.properties.postcode }} - {{ feature.properties.type }}
</li>
</ul>
`
})
export class HomeComponent implements OnInit {
public message: string;
public result: any;
constructor(private http: HttpClient) {}
ngOnInit() {
this.message = 'Hello';
this.http.get("https://api-adresse.data.gouv.fr/search/?q=seclin").subscribe(response => {
this.result = response;
});
}
}
Rebuild the application and launch the server:
> npm run build:ssr
> npm run serve:ssr
Now, if you visit http://localhost:4000 you will see the data returned by the API call, and if you look at the source code you will see that these data are present in the HTML code because the page have been generated and served by the server.
But wait, did you see that when you go to http://localhost:4000 you see that the API content is blinking ? And if you check your debug tool you can see that the browser has sent an XHR to the API.
This is because the content, which is already present in the HTML and already displayed, has been reloaded by the client application, after the transition from server to client, and displayed again. So now, you got 2 calls to the API, the first from the server and the second from the client after the bootstrap.
It’s not very cool, the blinking is bad for the user experience and the double call increases response times and the server load. But Angular provide a tool to avoid that, the TransferState
.
TransferState to the rescue
The Angular TransferState
(Angular 5+) provides tools to help transferring data from the server-side to the client-side of the app.
To do this, you have to import the ServerTransferStateModule
in your src/app/app.server.module.ts
:
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: [
// The AppServerModule should import your AppModule followed
// by the ServerModule from @angular/platform-server.
AppModule,
ServerModule,
ServerTransferStateModule,
ModuleMapLoaderModule,
],
// Since the bootstrapped component is not inherited from your
// imported AppModule, it needs to be repeated here.
bootstrap: [AppComponent],
})
export class AppServerModule {}
And the BrowserTransferStateModule
in your src/app/app.module.ts
:
import {BrowserModule, BrowserTransferStateModule} from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { AppComponent } from './app.component';
import { HomeComponent } from './home/home.component';
import {HttpClientModule} from "@angular/common/http";
@NgModule({
declarations: [
AppComponent,
HomeComponent,
],
imports: [
HttpClientModule,
BrowserTransferStateModule,
BrowserModule.withServerTransition({appId: 'my-app'}),
RouterModule.forRoot([
{ path: '', component: HomeComponent, pathMatch: 'full'},
{ path: 'lazy', loadChildren: './lazy/lazy.module#LazyModule'},
{ path: 'lazy/nested', loadChildren: './lazy/lazy.module#LazyModule'}
])
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Now, to get data from the API, rather than make the HTTP call we have to:
- Try to get data from
TransferState
- If data is not present in
TransferState
, call the API and set data toTransferState
To add this feature, we can modify our home.component.ts
like this:
import { Component, OnInit } from '@angular/core';
import {HttpClient} from "@angular/common/http";
import {makeStateKey, TransferState} from "@angular/platform-browser";
@Component({
selector: 'home',
template: `
<h3>{{ message }}</h3>
<ul>
<li *ngFor="let feature of result.features">
{{ feature.properties.city }} - {{ feature.properties.label }} - {{ feature.properties.postcode }} - {{ feature.properties.type }}
</li>
</ul>
`
})
export class HomeComponent implements OnInit {
public message: string;
public result: any;
constructor(private http: HttpClient, private transferState: TransferState) {}
ngOnInit() {
this.message = 'Hello';
let myTransferStateKey = makeStateKey<any>("myDatas");
if(this.transferState.hasKey(myTransferStateKey)) {
this.result = this.transferState.get(myTransferStateKey, {});
this.transferState.remove(myTransferStateKey);
} else {
this.http.get("https://api-adresse.data.gouv.fr/search/?q=seclin").subscribe(response => {
this.result = response;
this.transferState.set(myTransferStateKey, this.result);
});
}
}
}
Nice, now when the server executes the component the TransferState
is empty, so the API is called and the TransferState is fulfilled with the results on a specific key.
Then, when the client starts the application and executes the component, data is retrieved from TransferState specific key and no more API calls are needed!
Keep in mind
Some elements like window
, document
or other browser types do not exist on the server so they will not work. But if you want to use them, or if you want specific functionality on server or on client you can use 2 methods provided by Universal:
isPlatformBrowser
isPlatformServer
For example, you can use it like this in your components:
import { PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
constructor(@Inject(PLATFORM_ID) private platformId: Object) { ... }
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
// Client only code.
...
}
if (isPlatformServer(this.platformId)) {
// Server only code.
...
}
}
CLI 1.6 update
Since CLI 1.6, Angular is natively supporting Universal. You can then use this cli to start a project based on angular 5 with Angular integrating Universal:
> ng g universal APP_NAME
It makes all the front-end config for Universal.
Conclusion
Now you know that you can use Angular Universal to improve the perceived startup performance of your app, to facilitate web crawlers for SEO and to improve the UX.
The sources used for the examples are available on my Gitlab:
If you want to see an example with an Angular 4 application you can checkout this project on my Gitlab:
To go further you can visit the following pages: