- 10 minutes read

Adopting server-side rendering pays! Especially if you're running an Angular application on the web. Today, I activated Angular Universal for my blog, and it exceeded my wildest expectations. According to WebPageTest, SSR shaved up to 66% off the rendering time of the first page. That's what counts if you're into SEO. Google rewards fast websites, and it cares mostly about first contact. Once your reader or user is hooked, they tend to be patient. But on first contact, they're shy prey.

Nothing of this is new. So what was stopping me? Well, I'm stuck with a static web server. Even after migrating my blog to AWS, I shied away from using Fargate or EC2 to run my blog. These technologies are cheap if you're running a company, but the monthly $30 fee is heavy for a hobbyist like me. Static web servers are way more affordable. So AWS S3 is my choice. It's an excellent static webserver, especially when combined with Cloudfront.[1]

So let's figure out how to run Angular on a commodity server. I wouldn't call S3 "commodity", but my recipe also works for the average plain-vanilla web hoster.

TL;DR: modern Angular makes it a walk in the park!

What about AWS Lambdas?

Before taking off, let's talk about an affordable alternative. It's possible to run your Angular application on AWS Lambda, including server-side rendering. Adding CloudFront to the equation makes it both cheap and fast. Go for it if you're a company or don't invalidate your Cloudfront cache often.

So why didn't I use this solution?

I suspect that every page request results in a cold start of the Lambda. That's not a big deal: CloudFront mitigates this penalty. However, the cold start penalty is back when you invalidate the CloudFront cache. Admittedly, I didn't try it, and maybe it's not so bad. But it doesn't feel right.

So I'm happy to learn the Angular team cares about people like me.

Level 1: embracing SSR on a real server

Nowadays, adopting Angular Universal is very simple. There's an Angular schematic for that:

ng add @nguniversal/express-engine

It works well as long as your project follows the rules of server-side rendering. My project doesn't. The lesson I learned is not to run the schematic on your real-world project first. I've tried that twice, and it's no fun. Create a sandbox project first, add server-side rendering, play with the technology, and gather experience. If your boss is complaining, send them to me. I'm recommending it for a reason. In a real-world project, you almost certainly run into weird problems. I trust you'll be able to solve them sooner or later. More likely than not, that's later, not sooner. Better run a project that's guaranteed to work first. This way, you learn how things are meant to work. Plus - and that's important! - you learn they do work.

That's a surprisingly powerful psychological trick. Putting it in different terms, it's an example of "divide and conquer" or "separation of concerns." Knowing that the technology is valid tells you the fault is in your project. For some reason, this helps you figure out the problem. You're already familiar with the do's and don'ts and know how to read the error messages of the sandbox project.

Level 2: fixing your project

Be that as it may, at some point in time, you'll have to return to your real project. Did you use objects like window, document, or location? These objects aren't available when rendering the page on the server. The internet is full of tutorials on how to solve this, so let's keep it at that. Just a few hints on how I solved the problem:

  • document is easy because you can ask Angular to inject it: @Inject(DOCUMENT) private document: Document
  • window is a bit more challenging, but Juri Strumpflohner shows you the way: import { Injectable } from '@angular/core'; function _window() : any { // return the global native browser window object return window; } @Injectable() export class WindowRef { get nativeWindow() : any { return _window(); } } Read Juri's article to get the full story.
  • location is basically window.location, so I guess the hint above suffices.
  • setTimeout() isn't available on the server. Even better, you don't need it. Server-side rendering is about the first impression. What happens later is out of scope. So just don't do it. Guard your timeouts with an if (isPlatformBrowser(this.platformId)) statement. Sending an incomplete page is OK as long as it looks good enough.

Level 3: testing on a real server

The Angular schematic added a couple of useful scripts to your package.json. At this stage, they are your friend. Test your application with npm run dev:ssr and npm run build:ssr && npm run serve:ssr. If you're using Windows, split the latter command into two commands because Windows doesn't support the && operator.

I ran into a bug at this point, possibly because I insisted on using the production build. Usually, I use file name hashing to avoid cache issues when deploying a new version. For some reason, hashing was also active in the SSR build. But the script of the package.json refers to the unhashed file name main.js. You'll want to deactivate hashing in the SSR-related parts of your angular.json:

{ ..., "projects": { "my-project": { ..., "architect": { "server": { "builder": "@angular-devkit/build-angular:server", "options": { "outputPath": "dist/my-project/server", "main": "server.ts", "tsConfig": "tsconfig.server.json" }, "configurations": { "development": { "outputHashing": "none", <<<< add this line "optimization": false, "sourceMap": true, "vendorChunk": true }, "production": { "outputHashing": "none", <<<< add this line "fileReplacements": [ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.prod.ts" }, { "replace": "src/polyfills.prod.ts", "with": "src/polyfills.extra.ts" } ], "optimization": true, "sourceMap": false, "extractLicenses": true, "vendorChunk": false } }, "defaultConfiguration": "production" },

Great. A short time later, I met another surprise. Activating the production mode works a bit differently than usual. Instead of adding configuration=production to the command line, you add :production to the compile target. And, of course, you don't add it to the npm script. You add it to the ng command within the script definition in the package.json:

{ ..., "scripts": { "dev:ssr": "ng run my-project:serve-ssr:production", "build:ssr": "ng build && ng run my-project:server:production", },

It's instructive to look into the dist folder. It contains two subfolders:

  • dist/<my-project>/browser and
  • dist/<my-project>/server.

The server folder only contains a few JavaScript files. It's just an express server, plus a copy of your TypeScript code adapted to run on the server.

Plain-vanilla applications pass this test without further ado. Sigh. I've already mentioned my blog isn't a plain-vanilla application because it uses HttpClient to load the articles. That required some fine-tuning: the URL that works flawlessly on the client refuses to work on the server. I had to convert it to a relative URL, starting with ./.

That wasn't a big deal if it weren't for the confusing error message. Lesson learned: wrap your `httpClient.get() calls in an exception handler and print a helpful error message. The default error message is - pardon my words - crap.

Level 4: use npm run prerender

Now for the exciting part. Until now, everything we did requires a dynamic server executing JavaScript. How to use SSR on a server that doesn't speak JavaScript?

You'll love it: run npm run prerender . It's just that easy!

This command creates a folder for each route of your application containing an index.html file. The URL http://localhost/contact maps to somewhere/dist/<your-application>/browser/contact/index.html. Angular even tries to guess the routes based on the app-routing.module.ts.

Of course, Angular doesn't always guess right. If so, scan for "routes" in your angular.json, set guessRoutes to false, and define the set of routes to prerender:

{ ..., "projects": { "my-project": { ..., "architect": { "prerender": { "builder": "@nguniversal/builders:prerender", "options": { "routes": ["/", "/angular-server-side-rendering", "angular-async-await" ], "guessRoutes": false },

Nice, but it doesn't work for me for two reasons. My AWS S3 server doesn't recognize the folder structure npm prerender generates. I need files bearing the URL name, not folders containing an index.html. Maybe that's simply a question of configuring S3 correctly, but there's another show-stopper. My blog loads the articles lazily using httpClient.get(). And that doesn't work.

Angular's prerender script is pretty cool. Among other things, it doesn't even start the node.js server. That's great because it makes sure prerendering is fast. But if your application depends on httpClient.get() to display the initial page, npm run prerender is pretty much useless. Using the fs object of node.js might be possible, but I didn't try this approach because it felt too much like cheating. Instead, I abandoned the tools Angular gave me.

Level 5: curl to the rescue

We've got so far. Giving up isn't an option!

Luckily, we've tested server-side rendering in level 3. Level 4 didn't work, but we can still curl us to success. That's precisely what BeyondJava.net does.

A simplified version of my prerendering script looks like so:

npm run dev:ssr & sleep 20 mkdir ./dist/my-project/static file=angular-server-side-rendering curl "http://localhost:4200/$file" > ./dist/my-project/static/${file} file=angular-async-await curl "http://localhost:4200/$file" > ./dist/my-project/static/${file} file=index.html curl "http://localhost:4200/$file" > ./dist/my-project/static/${file}

You end up with two folders:

  • ./dist/my-project/browser is your bare-bone Angular application.
  • ./dist/my-project/static/ contains the prerendered HTML files of

I've put these files into different folders because aws s3 sync can't guess the MIME type of the prerendered files correctly. So I upload them separately, telling S3 these are HTML files:

aws s3 sync --delete ./dist/my-project/browser s3://my-project echo "uploading prerendered files" aws s3 sync --content-type text/html ./dist/my-project/static s3://my-project

Wrapping it up

It took me the better part of a day to migrate my blog to SSR, but I guess that's because I spend a lot of time playing with the technology, and my setup is non-standard. In particular, relying on

httpClient.get() to display the front page is something your application probably doesn't do. Refusing to pay for a real server isn't the default approach.

I'm pleasantly surprised that Angular Univeral nowadays supports this approach. To be honest, it's a late discovery. Angular 9 added prerendering three years ago.

Of course, there's more to say about server-side rendering. For example, you can transfer the result of httpClient.get() from the server to the client, thus eliminating an request.

But then, that's the game. There's always the next level. The five levels I've shown you already give you a head start!

  1. However, if a hacker runs an ambitious DDOS attack, there's no upper limit to the bill AWS charges you. Make sure to set up a WAF and react quickly. As a non-profit blogger, I'm following the most radical approach: shutting the entire website down before it gets expensive.