Integrate Server-Side Rendering (SSR) into your existing Angular blog site project
241111-19
About
This post is the 7th sequel of a series of posts on building an Angular-based blog site. The previous posts are listed below:
1. Angular: Use Tiles to implement a Holy-Grail layout for your blog
2. Angular: Add dynamic data to your blog using a Spring Boot backend and MariaDB
3. Containerize and automate the deployment of a 3-tier full-stack project
4. Angular: Update the frontend project and add HTML content support for your blog site
5. Angular: Revise the frontend project to incorporate Markdown content support for your blog site
6. Use URL Slugs for your Angular SPA blog website
In this post, we’re going to dive deep into how to convert our CSR Angular project into an SSR one. We’ll kick things off by installing the required package and breaking down how this installation impacts our project. Then, we’ll move on to the updates needed for our 3-tier, container-based setup, focusing particularly on the frontend (NGINX) container configurations.
Short intro to SSR and Hydration
Angular apps mainly rely on client-side rendering (CSR), meaning most of the rendering happens right in the user’s browser. This can create challenges for search engines since they tend to favor static content or Server-Side Rendering (SSR) for easier crawling and indexing. Additionally, trying to incorporate Search Engine Optimization (SEO) into an Angular project is pretty much a no-go for single-page applications (SPA) or CSR setups.
So, by implementing SSR, we get both fast initial loading with SEO optimization and interactive, user-friendly web pages.
SSR is all about creating HTML on the server while the app is running. Just a heads up, SSR doesn’t mean everything is created once and stays static; that’s more like what a SPA or CSR app does. The first “S” in SSR stands for Server, and typically, in a Node.js environment, that server is built on Express engine, which dynamically sends the rendered content to the browser.
Here’s a quick rundown of the SSR process:
- A user requests a page (like /home).
- The server uses the ssr package (ex. Angular Universal) engine to render the Angular app.
- The SSR app creates an HTML document on a Node/Express server.
- The server sends this fully rendered HTML document to the user’s browser (to the ‘client’).
- The client browser displays the initial HTML document.
- The client browser fetches and runs the client-side JavaScript files.
- The SSR application hydrates static HTML elements by attaching event listeners, state management, and dynamic content and makes the whole app making it interactive.
- After that, any user interaction is controlled by the client-side script until the page reloads (e.g., navigating to another page), similar to CSR applications.
Hydration is basically when the HTML that’s rendered on the server gets taken over by the JavaScript running on the client side. Angular makes this super easy since it handles hydration automatically. Once the server-side HTML is delivered to the client browser, Angular’s client-side code hydrates the page by attaching event listeners, state management, etc. and making it interactive.
You can read more in the official documentation here.
A. Adding the @angular/ssr package
The initial repo
Before proceeding to installation and all the related details, good to know, that I’ve set up a new repository, based on my earlier post (the 6th one). You can check out the kick-off commit of this repo here. (Note that now we use the Angular version 18.2.11). Next, we’ll build on this and continue our work.
What we are going to install
Simply, we are going to add the @angular/ssr package to our existing project. Be aware that we are not going to install @angular-universal. All the features of this package are moved into the Angular CLI repo from Angular version 17 and afterward, and now the @angular/ssr
package substitutes actually the ex. @angular-universal. (See more here).
When I was writing this post (241114) the latest version of the @angular/ssr package was the 18.2.11 version.
So, let’s do it! You can add the SSR package in an existing Angular app by using the following command:
ng add @angular/ssr
As you can see, the installation was pretty simple. However, it has caused some noticeable impact on our project.
The changes caused by the installation
Let’s see the changes.
The updates to the project configuration files
The updates you anticipated impact the three key project configuration files: package.json, Angular.json, and tsconfig.json. In addition to these changes, there’s also a minor update to the app.config.ts file. Let’s quickly go over those changes.
package.json
The following dependencies have been added:
dependencies:
"@angular/platform-server": "^18.2.11",
"@angular/ssr": "^18.2.11",
"express": "^4.18.2",
devdependencies:
"@types/express": "^4.17.17",
"@types/node": "^18.18.0",
And the following script (under scripts):
"serve:ssr:ang18-SSR-SEO-SupportBlog1": "node dist/ang18-SSR-SEO-SupportBlog1/server/server.mjs"
Angular.json
The following section has been added under build – options:
"server": "src/main.server.ts",
"prerender": true,
"ssr": {
"entry": "server.ts"
}
tsconfig.app.json
The tsconfig.app.json file has been updated as follows:
app.config.ts
The minor, yet important change to this file, is that the Client Hydration service has also been added as a service provider:
The 3 new files that have been added
In our case (where we use standalone components), when you now look at the structure of your project, you will see three interesting files that did not exist before (CSR-based project):
1. server.ts
This is responsible for creating/configuring the Node.js Express server which actually performs the server-side rendering, based on a CommonEngine function (the 2nd server.get() function in the server.ts file, below).
The 2nd server.get() function will always be executed (for all routes **) when a GET request is triggered by the client. As a result, this means that regardless of what path you go to in your application, the above logic will be performed before this transition is made.
The CommonEngine is the new Angular SSR rendering engine that replaces `ngExpressEngine` in the new setup. It provides a simpler, unified API for server-side rendering. As it is described in detail in the official documentation, the render method of CommonEngine accepts an object with the following properties:
The bootstrap method is imported from the src/main.server.ts file:
import bootstrap from './src/main.server';
which is actually bootstraps the server-side part of our app, as we will see below.
Finally note that the express server will listen to the port 4000, as you can see at the run() function (port 4000 is the pre-default port).
2. src/main.server.ts
This is actually, the bootstrapper for the server-side part of the app. It is analogous to the client-side bootstrapper main.ts.
It is really quite similar to the previous (CSR), main.js. Note that they use exactly the same function to run the bootstrapApplication app.
The only noticeable distinction between these two files is where the application’s ‘config’ is downloaded from.
So, as you can now understand, the bootstrap method, (for which we have previously referred to, looking at the express server file in the 2nd server.get() method) is responsible for rendering first and foremost the server-side of our app, and then any CSR request.
3. src/app/app.config.server.ts
This is the server-side application configuration (for projects using standalone components only).
You can see that this is actually responsible for creating the new app configuration, by “merging” the server configuration (the SSR part) with the previously well-known CSR ApplicationConfig (app.config.ts). This is via using the Angular function: mergeApplicationConfig, which does this job.
This keeps the CSR configuration (app.config.ts) independent, and at the same time, any change in this is also reflected in the new app (server) configuration.
Now that we have a better understanding of the changes brought on by the @angular/ssr installation package, let’s go ahead to build, see the outcome, and try our application.
How to build our Angular SSR project
We can build your Angular CLI application or library with the ng build command. This will compile your TypeScript code to JavaScript, as well as optimize, bundle, and minify the output as appropriate.
ng build only executes the builder for the build target in the default project as specified in angular.json.
The ng build invokes the Architect target named `build` and the default builder is the ‘@angular-devkit/build-angular:application’:
"builder": "@angular-devkit/build-angular:application",
The @angular-devkit/build-angular:application is the default bundle builder for applications generated by ‘ng new …’ command.
This builds an application with a client-side bundle, a Node server, and build-time prerendered routes with esbuild.
Note that, by default, the ‘ng build’ uses the production configuration.
The ‘ng build’ outputs the built artifacts to /dist/<our-project-name>/ by default, however this path can be configured with the outputPath option in the @angular-devkit/build-angular:browser builder
When we build an Angular SSR app, 2 sub-folders are created into the /dist/<our-project-name>/ folder: server and browser. Both required to Angular SSR app to run correctly.
Note that all Javascript modules generated, have been compiled to ESM (ECMAScript Modules) format (.mjs extension), which is compatible with modern Node.js environments.
Server-Side Rendering folder (dist/<project-app-name>/server/):
This folder contains server-side JavaScript chunk modules. The key files (modules) are the ‘main.server.mjs’, and the ‘server.mjs’. The ‘main.server.mjs’, (that represents the compiled outcome of the ‘main.server.ts’) is responsible for bootstrapping our app with the defined component (e.g. the AppComponent). The ‘server.mjs’ is the compiled outcome of the ‘server.ts’ (where we have defined the Express server settings), and it is the main entry point for the server-side Angular application. This means that it is the entry point for starting the Node.js (Express) server.
The ‘index.server.html’ serves as the base HTML template where Angular will inject the SSR-rendered response content. This file typically contains special placeholders like <!–app-root–> or <app-root></app-root>, and during SSR, Angular replaces these placeholders with the pre-rendered HTML content of the application.
The server-side rendering process typically follows these steps:
- The user makes a request (e.g., GET /about).
- The Angular SSR server (Node/Express – main.server.mjs) loads the index.server.html file from the filesystem.
- The server uses Angular’s renderModule() or ngExpressEngine() for generating pre-rendered HTML for the requested route.
- The rendered content is injected into index.server.html (replacing <app-root></app-root>).
- The fully hydrated HTML response is sent back to the client.
Client-Side Rendering folder (dist/<project-app-name>/browser/):
This folder contains assets like the JavaScript chunks (main-xxxxxxxx.mjs, polyfills- xxxxxxxx. mjs), CSS (styles- xxxxxxxx.css), static assets, and images (favicon.ico, etc.). It is used for client-side rendering when the Angular app is loaded by the browser.
The ‘index.csr.html` is used for Client-Side Rendering (CSR). It acts like a fallback for Client-Side Rendering (CSR). When the server-side rendered application fails (e.g., due to network issues, server unavailability, or errors in SSR processing), the client can fall back to rendering using this index.csr.html file.
Give a try to the SSR project
To start the Express server, we must run the server.mjs. file with the node, e.g.:
$ node dist/ang18-SSR-SEO-SupportBlog1/server/server.mjs
The 1st command response informs you that you can access the server via the port 4000.
Node Express server listening on http://localhost:4000
If you access it via your browser, you will see that the SSR works fine, and the initial layout is displayed pretty well. However, you can see the popped-up error alert, and the terminal and the console, also inform us about errors, e.g.:
The errors indicate that our backend is inaccessible. This is OK because the response comes from the Express server running in local machine and is listening to port 4000. Recall, that we use a NGINX server running in a container, and listens to port 80, mapped to localhost port 8001. So, we have to fix this.
Find the last commit, of our repo here.
B. Updating the frontend container
If you have already read my previous related post:
you are aware that our frontend Angular project runs by a frontend container. The frontend container is powered up by a running NGINX daemon, which is responsible for serving the built Angular app.
Find here the initial commit for the existing configuration. We will step on it to make our changes.
So, after we switched to the SSR, the frontend container settings needed to be updated. In short, the steps of the necessary updates are:
- We need to install and use Node.js to fire up the Express server and be able to serve our Angular SSR by loading the ‘server.mjs’ starting-point file.
- The frontend container must be powered on by starting both: the NGINX server and the Node/Express server.
- We can automate the process of reloading the updated ‘server.mjs’ whenever a newer ‘server.mjs’ file is sensed into the shared folder.
- We have to update the bash script ‘build-and-deploy.sh’ that builds and copies the output files to the frontend container-share folder
- We must configure the default NGINX server running in the frontend container, to redirect all the content to this Node-Express server.
The frontend Dockerfile
Creating and using a separate container for running a Node server and handling the SSR requests, may be an appropriate solution for bigger scalable projects (and in the future, we will probably do this). But for now, I prefer to install and run it within the same (frontend) container.
So, the existing Dockerfile should be updated, by also installing the node and npm packages.
Then, when we use the CMD Dockerfile command, to start the container, we can simply use the node to start the ‘server.mjs’ file, (simultaneously with the NGINX server) e.g.:
CMD ["sh", "-c", "nginx -g 'daemon off;' & node /usr/share1/nginx/wwwroot/server/server.mjs"]
However, this approach has some limitations, mainly because each container typically runs a single main process in the host system (and this is the key reason we have only 1 CMD command in each Dockerfile). Moreover, if either process (NGINX or Node.js) fails, the container will continue running because the other process is still active. This can make error handling more difficult.
So, it’s better to use a process manager like `supervisord`, `forever` or `PM2`, to handle multiple processes. Since in our backend container settings we have already see how the `supervisord` works, here I prefer to use the PM2 which is mainly, a node process manager.
Using the PM2 process manager
However, a better approach is to use a Node production process manager, like the PM2. We can configure pm2 using a JavaScript file (e.g.: ‘pm2.config.js’), for starting up a node-server service by running the ‘server.mjs’ file. Using PM2, is preferable for one more reason: the pm2 package is also able to monitor (‘watch’) a specific folder for changes/updates. This is essential because it allows us to reload the node server process, every time a newer/updated ‘server.mjs’ file is sensed into the target folder.
So, we can also use the RUN command in the Dockerfile to install the pm2 node package. Then we can instruct it to start the node server (server.mjs), after the NGINX server is powered up.
The Dockerfile can be updated like this:
As you can see above in the last line running the CMD Dockefile command, we use a specific bash script (the ‘start_frontend.sh’) to start up the container. This is necessary since it allows us to start up both the NGINX and the Node/Express server using the CMD command.
The container startup bash script
Here is the ‘start_frontend.sh’ bash script file:
You must put this file into the root of the frontend share folder and make it executable, in order to be runnable from the pm2 from within the container. (Recall that in our docker-compose.yaml file we have a mapped volume that maps the host ‘~/DOCKER_SHARE1/net2/frontend’ folder to the container’s ‘user/share1’ folder).
The pm2 configuration file
As we have mentioned earlier, we configure the pm2 settings, using the JavaScript file ‘pm2.config.js’, for starting up the node-server service by running the ‘server.mjs’ file.
Here is the ‘pm2.config.js’ script file:
The NGINX default server configuration file
The settings for the NGINX default server configuration, so far used to be within the ‘my-default’ file in the frontend root folder. Now I renamed it to ‘my-defalt.conf’.
The main required update concerns the redirection of all incoming requests “/” first to the Node/Express server, which by default runs on port 4000 (see the ‘server.ts’ of the Angular project). This can be done by using a proxy location block in an analogous manner we did with the location block “/blogapi/” that redirects those requests to the backend.
Moreover, we can optionally define another proxy location block for handling directly the “/assets/” static content.
So, the ‘my-defalt.conf’ becomes:
The schema below shows the basics of our 3-tier, container-based project:
That’s it! After all the above changes, now we can rebuild the frontend container and start it up again, e.g. by using the following docker compose commands:
$ docker-compose build --no-cache frontend
$ docker-compose up -d frontend
Find here the last updated commit of the repo providing full setup for a 3-layer, container-based Angular SSR app.
One more thing should be mentioned here: The bash script ‘build-and-deploy.sh’ that builds our Angular project and copies the output files to the frontend container-share folder is a file in our Angular project repo, but it is also given below.
The ‘build-and-deploy.sh’ bash script file:
Now you can run it and check again our frontend app. As you can see below, the app performs -as it is expected- pretty similar to the previous CSR-only version. However now it an SSR Angular application! And this happened just by adding the @angular/ssr package to our project!
Recap
In this article, we explored how to implement Server-Side Rendering (SSR) in our Angular blog, moving away from the usual Client-Side Rendering (CSR) method. In Part A, we concentrated on integrating the @angular/ssr package into our blog for SSR and discussed the changes that came with it. Then, in Part B, we reviewed the necessary updates to our frontend container’s configuration, focusing on how to manage the new Express server that handles SSR as a reverse proxy for our existing NGINX server.
Now, our site is set up for further enhancements and search engine optimization (SEO), which we will cover in an upcoming post.
So, that’s it for now! Thank you for reading, stay tuned and keep coding!