Angular Universal: an adventure

Add angular universal support to Angular 7/8 project.

Douglas Liu
Sohoffice
8 min readDec 24, 2018

--

Having upgraded my angular application to angular 7, the next most important thing is to get it running with angular universal.

If you have questions like what is angular universal, let me quote wikipedia here:

Angular Universal: a technology that runs your Angular application on the server

Not very comprehensive. But basically when your user arrives with a URL, angular universal generate and return the HTML in the server side. This makes sense to social crawler and some search engines. Try share an angular page without universal on facebook to find out why :)

Photo by Stanislav Kondratiev on Unsplash

There’s an official guide regarding how to use angular universal. This is a very comprehensive guide, but some points didn’t work for me. So I guess I’ll add my 2 cents.

1. Use the CLI

The angular CLI now supports generate universalwhich should save you a lot of typing time. In your application folder, just run the following:

ng generate universal --client-project <your project name>

You’ll discover the app.server.module.tsand many other required files have been generated, and the app.module.ts being amended. This should have also installed the @angular/platform-server dependency, but you need to make sure the version number is correct. Use the below command to re-install with the right version:

npm install --save @angular/platform-server@7.1.3

1a. Generate server bundle

This step is optional. The bundle will be generated later any way, but it’s useful if you want to confirm the server bundle path.

By now you should be able to generate your angular universal bundle.

ng run <your project name>:server

You should see dist/<your_project_name>-server/main.js being generated.

This guide was based on Angular v7, but most of the techniques can also be used on Angular v8. I would personally recommend checking out the Universal Starter project, if you need references.

2. Install the other dependencies

The CLI only installs the platform-server, but we also need the below to get it running for webpack or node express.

npm install --save @nguniversal/module-map-ngfactory-loader ts-loader @types/express

Some will also need the webpack-cli.

npm install --save-dev webpack-cli

3. Running in express

First add a server.ts in the root folder. I modified the script from the angular guide slightly to make the modification we need to make a bit clearer. Just make sure you modify the BROWSER_FOLDER and the server bundle path and you should be fine. Typically if your project is foo, it will be built to dist/foo. The generated server config will be built to dist/foo-server, as a result the bundle path will be ./dist/foo-server/main.

Check angular.json to confirm the paths.

import 'zone.js/dist/zone-node';
import 'reflect-metadata';
import {renderModuleFactory} from '@angular/platform-server';
import * as express from 'express';
import {readFileSync} from 'fs';
import {enableProdMode} from '@angular/core';
import {join} from 'path';

enableProdMode();

// Express server
const app = express();

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

// TODO Change this to match your browser dist folder
const BROWSER_FOLDER = join(DIST_FOLDER, 'foo');

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

// * NOTE :: leave this as require() since this file is built Dynamically from webpack
// TODO Change this to match your server dist folder and bundle name.
const {AppServerModuleNgFactory, LAZY_MODULE_MAP} = require('./dist/foo-server/main');

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

app.engine('html', (_: any, options: any, callback: any) => {
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', BROWSER_FOLDER);

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

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

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

4. Package server.ts

We’ll use webpack to compile server.ts into js and package the dependencies. Create a file webpack.server.config.js in your root folder to configure webpack.

Change the mode to development if you have problem running the express server.

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

module.exports = {
mode: 'production',
entry: { server: './server.ts' },
resolve: { extensions: ['.js', '.ts'] },
target: 'node',
// this makes sure we include node_modules and other 3rd party libraries
externals: [/(node_modules|main\..*\.js)/],
output: {
path: path.join(__dirname, 'dist'),
filename: '[name].js'
},
module: {
rules: [
{ test: /\.ts$/, loader: 'ts-loader' }
]
},
plugins: [
// Temporary Fix for issue: https://github.com/angular/angular/issues/11580
// for "WARNING Critical dependency: the request of a dependency is an expression"
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'),
{}
)
]
}

4a. Use tsc to compile server.ts without packaging

If you don’t mind the source codes not being built into bundles, Webpack is not strictly required.

Angular universal will be running in a controlled environment (our node server) and sources are usually only loaded once. These all makes bundling them with Webpack less appealing IMHO.

If you don’t want to use webpack, we could have used the typescript compiler to compile the server.ts. And the rest should just work.

# create a new file server.tsconfig.json in project root.
# the tsconfig.json will be used to compile server.ts
{
"compileOnSave": false,
"extends": "./tsconfig.json",
"include": ["server.ts"],
"compilerOptions": {
"outDir": "./dist",
"sourceMap": true,
"declaration": false,
"module": "commonjs",
"moduleResolution": "node",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"target": "es6",
"typeRoots": [
"node_modules/@types"
],
"lib": [
"es2017",
"dom"
]
}
}
# Update the below of server.ts to change the require path.
# server.ts will be compiled to /dist/server.js,
# the require will start from there.
#
# Before: './dist/foo-server/main
# After: './foo-server/main
// TODO Change this to match your server dist folder and bundle name.
const {AppServerModuleNgFactory, LAZY_MODULE_MAP} = require('./foo-server/main');

To compile server.ts, use the below:

tsc -p server.tsconfig.json

You can use this to replace the webpack:server task in below step 5.

5. Add scripts to simplify the job

The angular guide advise us to add script via angular.json. This did not work out for me as scripts is not a valid option for the server block. I’ll instead add it to package.json. Simply add the below to the existing scripts section in package.json. Remember to change your_project_name.

"build:all": "npm run build:client-and-server-bundles && npm run webpack:server","serve:server": "node dist/server.js","build:client-and-server-bundles": "ng build --prod --aot && npm run build:server","build:server": "ng run <your_project_name>:server","webpack:server": "webpack --config webpack.server.config.js --progress --colors"

Use npm run build:all to build and npm run serve:server to run a local express server.

6. Fix the browser dependencies

There’re things that won’t be available when doing server side rendering. For example the window object. If you have to use it, try inject the reference in your base module, so we can override it when performing server side rendering.

// app.module.ts@NgModule({
providers: [
{
provide: 'windowObject', useFactory: () => {
return window;
}
}
],})
export class AppModule {
}

When you want to use the window reference, inject it by name.

export class FooComponent implements OnInit {  constructor(@Inject('windowObject') private window: Window) {
}
}

Override the provider in app.server.module.ts.

// app.server.module.ts@NgModule({  imports: [
AppModule,
ServerModule,
],
... providers: [
{
provide: 'windowObject', useValue: {}
},
]
})
export class AppServerModule {}

According to the module override principle. Since AppModule is imported by AppServerModule, the provider in the server module will override the one in the app module. Of course, the server side implementation of windowObject must make sense. I only used windowObject for LocalStorage, so an empty object will work for me.

In some cases you may not be able to provide different implementation for server side, use isPlatformBrowser to determine whether a browser is in use.

export class FooComponent implements OnInit {
constructor(@Inject(PLATFORM_ID) private platformId: Object) {
}
... @ViewChild('dataInput') set input(el: ElementRef) {
this._input = el;
if (isPlatformBrowser(this.platformId) && this._input != null) {
this._input.nativeElement.focus();
}
}
}

7. StaticInjectorError[InjectionToken Application Initializer -> InjectionToken DocumentToken]

If you’ve encountered the this error. You must have realized, it did not happen in webpack development mode (configured via webpack.server.config.ts) It turns out the issue will go away when we set minimize: false.

// webpack.server.config.tsmodule.exports = {
mode: 'production',
devtool: "source-map",
... optimization: {
minimize: false
}

};

I think minification should not alter the behavior of the generated codes, so I consider this as a bug.

Conclusion

Simply use the CLI to add universal support for an existing angular project. Refer to the angular guide for what’s being done by the CLI, but you don’t have to do it all by yourself.

Deal with the differences of server / browser platform, although they are not too different. Just remember the principles:

  • Use what angular provides. for example document is already part of the framework, Renderer2 is also well supported in angular universal.
  • Use injection for those object that angular framework do not yet have.
  • Use platform conditional if else if you have to do something only in browser but can’t use the above approaches.

Add optimization.minimize: false to webpack.server.config.ts, if you encounter the StaticInjectorError. But make sure you try development mode of webpack first, your issue may be caused by something else.

Consider to replace webpack with tsc (see step 4a). I think this greatly simplifies the build process, and the benefit of webpack in our use case is not very significant.

Thoughts

The above may have setup Angular Universal for your project, but you need to realize it may actually slow down your application ! (Surprised ~)

The thing is, all initial request to your application is now rendered on server side. If you look carefully in the browser’s developer console, you’ll discover the html body was already there for the Doc request. Those html didn’t come from nowhere, the Angular Universal is actually calling all required components, making remote API call if necessary and composed the html. This obviously will use more time and resources on the server side, where the original angular model allow all magic to happen on the browser side. If your server do not have much computing power, the server-side rendering process will only slow it down.

In my opinion, angular universal is more useful in detail page where you want to display something like product contents and images for example. The social media friendly nature allow your user to share the page and enjoy a nice preview. There may be thousands or more of such pages, depending on your dataset and routing settings, pre-render all of them may not be realistic. Given the dynamic nature of these pages, pre-render may not be a good idea.

Theoretically angular universal can be useful to personalized pages, but that will only work if the server have access to who the user is. SPA application do not usually have a server session, so you may want to store your user info in cookie so the user identity can be accessed in server side. You may want to think about whether you truly need to do it for those pages.

Some rather static pages may have the html pre-rendered. Say for example, your homepage may not have dynamic content each time it is served. So you should be able to pre-render the page and allow node express to serve the now-static html file when accessed. Of course, you’ll have to manage the rebuild of these pages.

How to balance between the server and client will become an issue very quickly. It turns out making the application runs with angular universal is only the beginning of the task.

Did you learn something new? If so please:

clap 👏 button below️ so more people can see this

--

--

Douglas Liu
Sohoffice

Problem solver. Found love in Scala, Java, Angular and more …