A previous article explained why server-side rendering is critical for SEO, here we explained how to set it up with Angular Universal.

JS application configuration

On the client side

If you have built your Angular project with Angular CLI (which we recommend), you will need some extra tools:

npm install ts-loader webpack webpack-node-externals --save-dev

In the module, you will change the BrowserModule import:

@NgModule({

imports: [

BrowserModule.withServerTransition({ appId: 'super-app-universal' }),

That's how we guarantee the app can be initialized from a server-rendered version properly in the browser.

You will need a specific module for the server mode; let's call it src/app.server.ts:

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

We import the regular app module so the server module shares the same appId.

That's it regarding the app itself.

Warning: if you name this file src/app.server.module.ts (mainly articles recommend that), make sure you add the --module parameter when you generate new components with ng. If you don't, ng will find several *.module.ts in ./src and will not know which one to update (the error message is not very explicit: "SilentError: Multiple module files found").

On the server side

On the server side, you will need a TypeScript file which will be compiled into JavaScript run by NodeJS, src/server.ts:

// src/server.ts

import 'reflect-metadata'; import 'zone.js/dist/zone-node'; import { platformServer, renderModuleFactory } from '@angular/platform-server'; import { enableProdMode } from '@angular/core'; import { AppServerModuleNgFactory } from '../dist/ngfactory/src/app/app.server.ngfactory'; import * as express from 'express'; import { readFileSync } from 'fs'; import { join } from 'path'; const PORT = process.env.PORT || 4000;

const IP = process.env.IP || '0.0.0.0';

const viewDir = __dirname; enableProdMode(); const app = express(); let template = readFileSync(join(__dirname, '..', 'dist', 'index.html')).toString(); app.engine('html', (_, options, callback) => { const opts = { document: template, url: options.req.url }; renderModuleFactory(AppServerModuleNgFactory, opts) .then(html => callback(null, html)); }); app.set('view engine', 'html'); app.set('views', viewDir); app.get('*.*', express.static(join(__dirname, '..', 'dist'))); app.get('*', (req, res) => { res.render('index', { req }); }); app.listen(PORT, () => { console.log(`listening on http://${IP}:${PORT}!`); });

Regarding compilation, we need to make sure we generate the ngfactory in our build because Node will need them. So in tsconfig.json, we add:

"angularCompilerOptions": { "genDir": "./dist/ngfactory",

"skipMetadataEmit": true

}

and in src/tsconfig.app.json :

"extends": "../tsconfig.json", "exclude": [ "test.ts", "server.ts",

"**/*.spec.ts"

]

And you also need a small webpack configuration to bundle the Node version, webpack.config.js (create it, at the root of the project, if it doesn't exist):

// webpack.config.js

const path = require('path');

const webpack = require('webpack'); const nodeExternals = require('webpack-node-externals'); module.exports = { entry: { server: './src/server.ts' }, resolve: { extensions: ['.ts', '.js'] }, target: 'node',

plugins: [ new webpack.NormalModuleReplacementPlugin(/\.\.\/environments\/environment/, '../environments/environment.prod') ], externals: [nodeExternals({ whitelist: [ /^angular2-schema-form/, /^angular-traversal/, /^ng2-auto-complete/, /^ng2-bootstrap/, /^@plone\/restapi-angular/ ] })], node: { __dirname: false,

__filename: false }, output: { path: path.join(__dirname, 'dist'), filename: '[name].js' }, module: { rules: [ { test: /\.ts$/, loader: 'ts-loader' } ] } }

The important part here is nodeExternals. It allows to exclude the project dependencies from the Node bundle. Indeed, most of the Angular packages are published in ES6 format, but Node uses CommonJS, so if they are included, we get syntax errors (typically: Unexpected token export ou Unexpected token import).

Don't forget the line starting with webpack.NormalModuleReplacementPlugin which replaces environment with environment.prod. When using Angular CLI, it is done automatically, but here we are in our own.

And finally, we add two NPM scripts in package.json :

"start": "node dist/server.js", "build": "ng build --prod && ngc && webpack",

As you can see, we keep the regular build by ng but we also run ngc (to get the ngfactory we mentionned earlier).

To start your app on Universal, just type:

npm start

On the client side, with a simple project, there is no need to change the app module. But it is a good practice to create a specific module for client named AppBrowserModule for instance in a file named app.browser.module.ts, it will import the AppModule just like AppServerModule does. You will have to update main.ts to replace with AppBrowserModule all the references to AppModule, which will become a kind of abstract module. In AppBrowserModule, you will import the modules that should not be loaded in the server side.

What about the versions?

As of today, you will need:

Angular >= 4.1.2

Webpack >= 2.2 et < 3.x

TypeScript >= 2.3.2, <= 2.3.4

uglify-js < 3.0

Server configuration (Nginx) You probably already have a static server in charge of serving your regular app for production. You need now a Node server that will serve it for bots. You will need an upstream and an extra location to call the Node server... upstream universalnode { server localhost:4000; }

location @prerender {

proxy_pass http://universalnode

} ...and a way to enable this location if the request comes from a robot. With the following setup, we set a prerender variable to 1 if we detect a robot user-agent (make sure to keep this updated!): map $http_user_agent $prerender_via_agent {

default 0;

"~(?i)googlebot|yahoo|bingbot|baiduspider|yandex|yeti|yodaobot|gigabot|ia_archiver|facebookexternalhit|twitterbot|developers\.google\.com" 1;

}



map $args $prerender_via_uri {

default 0;

"~(_escaped_fragment_|prerender)=1" 1;

}



map "$prerender_via_agent_prerender_via_uri" "$prerender" {

default 0;

"~1" 1;

} and we change our existing static resources location to use the @prerender location: error_page 430 = @prerender;

location / {

if ( $prerender = 1 ) {

return 430;

}

try_files $uri$args $uri$args/ /index.html;

}

Development process

If you plan to use Universal, you would be better to set it up at beginning and regularly check it works fine.

The pitfalls to avoid

Universal is not able to use global objects existing at runtime in your browser like document, window, localStorage, etc.

Similarly, the DOM must manipulated only by Angular, and no external ways (jQuery, vanilla javascript…), which is not an Angular best practice anyway (but be careful if your team is not experimented with Angular).

If your app needs to use window or any browser global object (or, more likely, if it calls libraries which does), you will have to test the context first. Angular provides isPlatformBrowser and isPlatformServer for that:

import { Inject, PLATFORM_ID } from '@angular/core'; import { isPlatformBrowser } from '@angular/common'; class MyService { constructor(@Inject(PLATFORM_ID) private platformId: Object) {} store(key: string, value: string) { if(isPlatformBrowser(this.platformId)) { localStorage.setItem(key, value); } } }

That's how you turn your code to Angular Universal compliant.

If you use animations, they must be enabled for the client only. BrowserAnimationsModule, the animation module, will be imported in AppBrowserModule only, and in AppServerModule, you will import NoopAnimationsModule which is a mock.

// app.server.js

@NgModule({

imports: [

ServerModule,

AppModule,

NoopAnimationsModule

],

bootstrap: [AppComponent]

})

export class AppServerModule { }

Your app makes probably some HTTP requests. That's handled properly by Universal: it waits for all the HTTP subscriptions to be executed before returning the page content. The only change that will be needed is to use absolute URLs only.

Be careful with all the delayed or scheduled operations based on operators like repeatWhen, delay, etc. They can slow down or even block the response!

The server will not support cookies. The good news here is you will probably not need them. If you use Angular for SEO, you do not want to render authentication restricted content anyway. And if you use Universal not just for robots but for actual visitors, the authentication will be done on the client side. Just make sure to restrict all services based on cookies to the client (put them in AppBrowserModule only).

Server side rendering is not compliant with module lazy loading. If you really need lazy loading on the client side, you will have to create two modules, one with lazy loading for the front, and one eagerly loading for the back. A future article will detail this approach.

SEO optimisation and social networks

In your existing app, you may not manage the HEAD tag content yet, but in a server rendering context, it might be useful. That's very easy to do, Angular provides services for that, and they work perfectly with Universal:

import { Meta, Title } from '@angular/platform-browser'; ... constructor( private meta: Meta, private title: Title, ) {} ngOnInit() { this.title.setTitle('Ma page'); this.meta.updateTag({ name: 'description', content: '...my description' }); }

Here, we just add a description meta, but it goes the same to create the Twitter Card or OpenGraph meta.

You might also want to publish a robots.txt and a sitemap.xml.gz file in your site root.

The robots.txt file can be created in src/ directly, you just have to mention it in the assets entry in .angular-cli.json so it is included in the build.

You can do the same with sitemap.xml(.gz), but its content is probably dynamic. If your backend is a CMS (like Plone in our case), you can just report your backend sitemap.xml(.gz) URL in the robots.txt.

HTTP error codes

By default, you will only get HTTP 200 code from your Node universal app. The app is designed to run in the client side, so even a bad route still render a page. If you need to return 404, 410, 500, etc. to robots requests, you can inject the Express response object in your Angular code with the extraProviders attribute. Here we inject both request and response:

// update server.ts file with following lines:

app.engine('html', (_, options, callback) => {

const opts: PlatformOptions = {

document: template,

url: options.req.url,

extraProviders: [

<ValueProvider>{

provide: 'REQUEST',

useValue: options.req

},

<ValueProvider>{

provide: 'RESPONSE',

useValue: options.req.res,

}]

};

renderModuleFactory(AppServerModuleNgFactory, opts)

.then(html => {

return callback(null, html)

});

});

In the component in charge of displaying the 404 page, you will do something like this:

constructor(@Inject(PLATFORM_ID) platformId: Object, @Inject('RESPONSE') response: Object) {}

ngOnInit() {

if (!isPlatformBrowser(platformId)) {

this.response.statusCode = 404

}

}

Your turn now

Did you set it up? Do you have feedback to share? Do you face issues? Contact us if you need help, or order a SEO audit!