This content originally appeared on DEV Community and was authored by Alexander Thalhammer
This comprehensive post includes a quick introduction to SSR, a detailed setup guide and several best practices with Angular 17 or even 18 (released on May 22nd, 2024), enhancing the initial load performance and thus the user experience of modern Angular applications. While we do not recommend updating production apps to V18 at the time of writing this post, most of the presented SSR features are already stable in Angular 17. The new Event Replay feature of V18 can easily be added later on. Nevertheless, if you want to use Material and/or CDK with SSR, you might want to upgrade to V18 as soon as possible.
In any case, make sure you have already updated to V17. If not, follow my Angular 17 upgrade guide, including the recommended migrations.
The Angular team has recently (well actually for quite some time) been putting in a huge effort and doing a fantastic job to help us improve the initial load time. SSR plays a significant role in achieving that goal for our framework of choice. Read my post from last July to learn why initial load performance is so crucial for your Angular apps.
Essentials
Let’s start with the basics. You can, of course, skip this section if you’re already familiar with SSR, and continue with the next section about building.
Server-Side Rendering (SSR)
Server-Side Rendering (SSR) is a web development technique where the (in our case node) server generates the HTML content of a web page (in our case with JavaScript), providing faster initial load time. This results in a smoother user experience, especially for those on slower networks (e.g. onboard a train in or
– which I happen to be a lot recently
) or low-budget devices. Additionally, it improves SEO and crawlability for Social Media and other bots like the infamous ChatGPT.
New Angular CLI projects will automatically prompt SSR (since Angular 17):
ng new your-fancy-app-name
For existing projects simply run the ng add
command (since Angular 17):
ng add @angular/ssr
Warning: You might have to fix stuff manually (like adding imports of CommonJsDependencies) after adding SSR to your project
Follow the angular.dev guide for detailed configuration. However, I’d recommend switching to the new Application Builder, which has SSR and SSG baked in. Let’s first clarify what SSG does.
Static Site Generation (SSG)
Static Site Generation (SSG) or Prerendering (like the Angular framework likes to call it), is the technique where HTML pages are prerendered at build time and served as static HTML files. Instead of executing on demand, SSG generates the HTML once and serves the same pre-built HTML to all users. This provides even faster load times and further improves the user experience. However, since the HTML is being stored on the server this approach is limited whenever live data is needed.
Hydration (since NG 16)
Hydration is the process where the prerendered static HTML, generated by SSR or SSG, is enhanced with interactivity on the client side. After the initial HTML is delivered and rendered in the browser, Angular’s JavaScript takes over to “hydrate” the static content, attaching event listeners and thus making the page fully interactive. This approach combines the fast initial load times of SSR/SSG with the dynamic capabilities of a SPA, again leading to a better overall user experience.
Before Angular’s Hydration feature, the prerendered static DOM would have been destroyed and replaced with the client-side-rendered interactive version, potentially resulting in a layout shift or a full browser window flash aka content flicker – both leading to bad results in performance tools like Lighthouse and WebPageTest. In my opinion, Angular SSR was not production-ready until supporting Non-Destructive Hydration. This has changed in 2023 since this feature has already become stable in Angular 17.
BTW, it’s super easy to enable Hydration in Angular
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(), // use NG 16 hydration
],
};
If you’re still using NgModules (for reasons), it becomes:
@NgModule({
providers: [provideClientHydration()],
})
export class AppModule {}
Event Replay (in Developer Preview since NG 18)
This example was taken from the official Angular blog. Consider an app that contains a click button like this:
<button type="button" (click)="onClick()">Click</button>
Previously, the event handler (click)="onClick()"
would only be called once your application has finished Hydration in the client. With Event Replay enabled, JSAction is listening at the root element of the app. The library will capture events that (natively) bubble up to the root and replay them once Hydration is complete.
If implemented, Angular apps will stop ignoring events before Hydration is complete and allow users to interact with the page while it’s still loading. There is no need for developers to do anything special beyond enabling this feature.
And again, it’s super comfy to enable Event Replay in your app
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
withEventReplay(), // use hydration with NG 18 event replay
),
],
};
Note: At the time of writing this feature is still in Developer Preview, please use it with caution.
Build
Since Angular 17 we have two options for building our Angular app.
Angular’s new Application Builder (all-in-one)
As mentioned, I’d recommend switching to the new Application Builder using esbuild and Vite. The advantage of using esbuild over Webpack is that it offers faster build times and more efficient and fine-grained bundling. The significantly smaller bundle also leads to better initial load performance – with or without SSR! Vite is a faster development server supporting extremely fast Hot Module Replacement (HMR).
Additionally, both SSR and Prerendering (SSG) are enabled by default as mentioned in this screenshot from the Angular Docs showing a table of the Angular Builders (note that the @angular-devkit/build-angular:server
is missing here):
Simply run ng b
to trigger a browser
and server
build in one step. Angular will automatically process the Router configuration(s) to find all unparameterized routes and prerender them for you. If you want, you can add parameterized routes via a txt file. To migrate, read my automated App Builder migration guide.
If still using Webpack (for reasons)
If – for any reason – you’re still committed to using Webpack to build your web app, you need the browser builder to be configured in your angular.json
(might be in project.json
if you’re using Nx). This will, of course, be added automatically once you run ng add @angular/ssr
.
{
"server": {
"builder": "@angular-devkit/build-angular:server",
"options": {
"outputPath": "dist/your-fancy-app-name/server",
"main": "server.ts",
"tsConfig": "tsconfig.server.json"
}
}
}
Note: The referenced server.ts
lies in the project’s root and is the entry point of your server application. With this dedicated server builder, there is also a dedicated tsconfig.server.json
(whereas the new Application Builder recommended previously merges the two tsconfig files for more convenience)
Now let’s quickly have a look at the build scripts:
Important note: If you haven’t started using pnpm
, you’re missing out. However, of course, both npm run ...
and yarn ...
will also work instead of pnpm ...
.
pnpm dev:ssr
ng run your-fancy-app-name:serve-ssr
Similar to ng s
, which offers live reload during development, but uses server-side rendering. Altogether, it’s a bit slower than ng s
and won’t be used a lot apart from quickly testing SSR on localhost
.
pnpm build:ssr
ng build && ng run your-fancy-app-name:server
Builds both the browser
application and the server
script in production mode into the dist
folder. Use this command when you want to build the project for deployment or run performance tests. For the latter, you could use serve or a similar tool to serve the application on your localhost
.
Deploy
You have two options for deployment. While both are technically possible, I’d recommend using the second one.
Using on-demand rendering mode via node server
Starts the server for serving the application with node using SSR.
pnpm serve:ssr
node dist/your-fancy-app-name/server/main.js
I’ve shown a detailed example Docker container here.
Caution: Angular requires a certain Node.js version to run, for details see the Angular version compatibility matrix.
Using build time SSR with SSG (recommended)
This option doesn’t need a node environment on the server and is also way faster than the other one.
pnpm prerender
ng run your-fancy-app-name:prerender
Used to generate an application’s prerendered routes. The static HTML files of the prerendered routes will be attached to the browser
build, not the server
. Now you can deploy your browser
build to whatever host you want (e.g. nginx). You’re doing the same thing as without SSR with some extra directories (and index.html
files).
Important note: If you’re using the new (and recommended) Application Builder, you can skip these steps for building and prerendering since they’re already included in ng b
. In other words, you have zero extra work for building including SSR & SSG – pretty great, huh?
Debug
The first step in debugging is looking for misconfigurations in your angular.json
(project.json
) or some errors in your server.ts
. If both look good, there is no definite way to debug SSR and SSG issues. Feel free to contact me if you’re experiencing any troubles.
How to avoid the most common issue
Browser-specific objects like document, window, localStorage, etc., do NOT exist on the server
app. Since these objects are not available in a Node.js environment, trying to access them results in errors. This can be avoided by using the document injector or by running code explicitly in the browser:
import { Component, inject, PLATFORM_ID } from "@angular/core";
import { DOCUMENT, isPlatformBrowser, isPlatformServer } from "@angular/common";
export class AppComponent {
private readonly platform = inject(PLATFORM_ID);
private readonly document = inject(DOCUMENT);
constructor() {
if (isPlatformBrowser(this.platform)) {
console.warn("browser");
// Safe to use document, window, localStorage, etc. :-)
console.log(document);
}
if (isPlatformServer(this.platform)) {
console.warn("server");
// Not smart to use document here, however, we can inject it ;-)
console.log(this.document);
}
}
}
Browser-Exclusive Render Hooks
An alternative to injecting isPlatformBrowser
are the two render hooks afterNextRender
and afterRender
, which can only be used within the injection context (basically field initializers or the constructor of a component):
The afterNextRender
hook, takes a callback function that runs once after the next change detection – a bit similar to the init lifecycle hooks. It’s used for performing one-time initializations, such as integrating 3party libs or utilizing browser APIs:
export class MyBrowserComponent {
constructor() {
afterNextRender(() => {
console.log("hello my friend!");
});
}
}
If you want to use this outside of the injection context, you’ll have to add the injector:
export class MyBrowserComponent {
private readonly injector = inject(Injector);
onClick(): void {
afterNextRender(
() => {
console.log("you've just clicked!");
},
{ injector: this.injector },
);
}
}
The afterRender
hook, instead, is executed after every upcoming change detection. So use it with extra caution – same as you would do with the ngDoCheck
and ng[Content|View]Checked
hooks because we know that Change Detection will be triggered a lot in our Angular app – at least until we go zoneless, but that story that will be presented in yet another blog post
export class MyBrowserComponent {
constructor() {
afterRender(() => {
console.log("cd just finished work!");
});
}
}
If you’d like to deep dive into these hooks, I recommend reading this blog post by Netanel Basal.
Angular Hydration in DevTools
The awesome Angular collaborator Matthieu Riegler has recently added hydration debugging support to the Angular’s DevTools! Which are, besides all Chromium derivatives, also available for Firefox, but then why would somebody still use that Boomer browser?
Note the for hydrated components. Even though this feature was announced in the Angular 18 update, it also works in past versions.
Other SSR Debugging Best Practices
Here is a collection of some more opinionated debugging recommendations:
- DevTools: Besides the updated Angular DevTools tab, inspect your HTML with the Elements tab and your API requests with the Network tab. BTW, you should also simulate a slow connection here when performance testing your app.
-
Console: I personally like to log everything into my Console. Not interested in a logger lib since I’m fine with console.log() and maybe some other levels. Any console logs will be printed into the terminal where
ng b
orpnpm dev:ssr
orpnpm serve:ssr
has been run. We don’t need to talk about logging into the browser’s console on production, or do we? -
Node.js: Start your SSR server with the –inspect flag to get more information:
node --inspect dist/server/main.js
- Fetching: Ensure all necessary data is available at render time. Use Angular’s TransferState to transfer data from the server to the client.
-
Routing: Make sure all routes are correctly configured and match on both the
browser
andserver
builds. -
Environments: Ensure environment variables are correctly set up for both
browser
andserver
builds. - 3rd-party Libs: As always, be very careful about what you include in your project. Some libraries might not be implemented correctly and thus not work in an SSR context. Use conditional imports or platform checks to handle these cases or, even better, get rid of those libs in the first place.
That’s all I have got so far. If you’ve got anything to add, feel super free to contact me!
Advanced
Disable Hydration for Components
Some components may not work properly with hydration enabled due to some issues, like DOM Manipulation. As a workaround, you can add the ngSkipHydration
attribute to a component’s tag to skip hydrating the entire component.
<app-example ngSkipHydration />
Alternatively, you can set ngSkipHydration
as a host binding.
@Component({
host: { ngSkipHydration: "true" },
})
class DryComponent {}
Please use this carefully and thoughtfully. It is intended as a last-resort workaround. Components that have to skip hydration should be considered bugs that need to be fixed.
Use Fetch API instead of XHR
The Fetch API offers a modern, promise-based approach to making HTTP requests, providing a cleaner and more readable syntax compared to the well-aged XMLHttpRequest
. Additionally, it provides better error handling and more powerful features such as support for streaming responses and configurable request options. It’s also recommended to be used with SSR by the Angular team.
To enable it, simply add withFetch()
to your provideHttpClient()
:
export const appConfig: ApplicationConfig = {
providers: [provideHttpClient(withFetch())],
};
If you’re still using NgModules (for reasons), this becomes:
@NgModule({
providers: [provideHttpClient(withFetch())],
})
export class AppModule {}
Configure SSR API Request Cache
The Angular HttpClient will cache all outgoing network requests when running on the server. The responses are serialized and transferred to the browser as part of the server-side HTML. In the browser, HttpClient checks whether it has data in the cache and if so, reuses that instead of making a new HTTP request during the initial load. HttpClient stops using the cache once an application becomes stable in the browser.
By default, HttpClient caches all HEAD
and GET
requests that don’t contain Authorization or Proxy-Authorization headers. You can override those settings by using withHttpTransferCacheOptions
when providing hydration:
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
withEventReplay(),
withHttpTransferCacheOptions({
filter: (req: HttpRequest<unknown>) => true, // to filter
includeHeaders: [], // to include headers
includePostRequests: true, // to include POST
includeRequestsWithAuthHeaders: false, // to include with auth
}),
),
],
};
Use Hydration support in Material 18 and CDK 18
Starting with Angular Material 18, all components and primitives are fully SSR and Hydration compatible. For information, read this blog post. On how to upgrade your Angular Material app, consult the docs on migrate from Material 2 to Material 3.
Combine SSR for static & CSR for user content
The future is here! With Angular 17 Deferrable Views you can easily mix SSR/SSG with CSR
The usage is pretty straightforward: Currently, all @defer
components will render their @placeholder
on the server and the real content will be loaded and rendered once they have been triggered (by on or when) in the browser. Learn more about how to use and trigger Deferrable Views.
Here are some primitive examples of how to combine SSR and CSR:
- Static pages: Use SSR
- Static content with live updates: Use deferred components for the live content and SSR for the rest
- Product list with prices depending on the user: Defer price components and use SSR for the rest
- List with items depending on the user: Defer the list component and use SSR for the rest
So basically, everywhere you need CSR (e.g. for user-dependent content), you need to @defer
those parts. Use the @placeholder
(and @loading
) to show spinners or equivalents to inform the user that something is still being loaded. Also, make sure to reserve the right amount of space for the deferred components – avoid layout shifts at all costs!
SEO and Social Media Crawling
If you want to look good on Google and/or social media platforms, make sure to implement all the necessary meta tags in SSR. For a comprehensive list, including some tools and tips, jump here.
export class SeoComponent {
private readonly title = inject(Title);
private readonly meta = inject(Meta);
constructor() {
// set SEO metadata
this.title.setTitle("My fancy page/route title. Ideal length 60-70 chars");
this.meta.addTag({ name: "description", content: "My fancy meta description. Ideal length 120-150 characters." });
}
}
Use SSR & SSG within AnalogJS
AnalogJS is the meta-framework built on top of Angular – like Next.js (React), Nuxt (VueJS), SolidStart (Solid). Analog supports SSR during development and building for production. If you want to know more, read the announcement of version 1.0 by Brandon Roberts or wait for my upcoming blog post
Angular SSR & SSG featuring I18n
Since the Angular I18n only works during built-time, it’s fairly limited. Therefore, we recommend using Transloco (or NGX-Translate). When adding Transloco by running ng add @jsverse/transloco
, you’ll be prompted for SSR usage. However, you can also manually add the necessary changes for SSR (see Transloco Docs):
@Injectable({ providedIn: "root" })
export class TranslocoHttpLoader implements TranslocoLoader {
private readonly http = inject(HttpClient);
getTranslation(lang: string) {
return this.http.get<Translation>(`${environment.baseUrl}/assets/i18n/${lang}.json`);
}
}
export const environment = {
production: false,
baseUrl: "http://localhost:4200", // <== provide base URL for each env
};
This will SSR everything in the default language and then switch to the user’s language (if different) in the browser. While this generally works, it’s definitely not ideal to see the text being swapped. Furthermore, we need to ensure there are no layout shifts upon switching! If you come up with any ideas on how to improve this, please contact me!
Caution with Module / Native Federation
At the time of writing this post, the Angular Architects’ federation packages do not support SSR:
- Module Federation using custom Webpack configurations under the hood and
-
Native Federation same API – but using browser-native Import Maps and thus also working with
esbuild
You won’t be able to use SSR out of the box when you set up a federated Angular app. While there are plans to support that, we currently cannot provide a date when this will be possible.
For the time being the master of module federation Manfred Steyer introduced an interesting approach, combining SSR with native federation. If the microfrontends are integrated via the Angular Router, then a server-side and a client-side variant can be offered per routes definition:
function isServer(): boolean {
return isPlatformServer(inject(PLATFORM_ID));
}
function isBrowser(): boolean {
return isPlatformBrowser(inject(PLATFORM_ID));
}
const appRoutes = [
{
path: "flights",
canMatch: [isBrowser],
loadChildren: () => loadRemoteModule("mfe1", "./Module").then((m) => m.FlightsModule),
},
{
matcher: startsWith("flights"),
canMatch: [isServer],
component: SsrProxyComponent,
data: {
remote: "mfe1",
url: "flights-search",
tag: "app-flights-search",
} as SsrProxyOptions,
},
];
Learn more about this approach in this article on devm.io or check out the ssr-islands
branch of Manfred’s example on GitHub to see an implemented example. While this setup reduces conflicts by isolating microfrontends, it introduces complexity in maintaining separate infrastructure code for both the client and server sides, making it challenging. Therefore, it’s crucial to assess if this trade-off suits your specific project needs and meets your architecture and performance goals.
Caution with PWA
Be careful if you are using Angular SSR in combination with the Angular PWA service worker because the behavior deviates from default SSR. The initial request will be server-side rendered as expected. However, subsequent requests are handled by the service worker and thus client-side rendered.
Most of the time that’s what you want. Nevertheless, if you want a fresh request you can use the freshness
option as Angular PWA navigationRequestStrategy
. This approach will try a network request and fall back to the cached version of index.html
when offline. For more information, consult the Angular Docs and read this response on Stack Overflow.
Workshops
If you want to deep dive into Angular, we offer a variety of workshops – both in English and German.
Outlook
Partial Hydration (NG 19 or 20)
Partial hydration, announced at ng-conf and Google I/O 2024, is a technique that allows incremental hydration of an app after server-side rendering, improving performance by loading less JavaScript upfront.
@defer (render on server; on viewport) {
<app-deferred-hydration />
}
The prototype block above will render the calendar component on the server. Once it reaches the client, Angular will download the corresponding JavaScript and hydrate the calendar, making it interactive only after it enters the viewport. This is in contrast to full hydration, where all the components on the page are rendered at once.
It builds upon @defer
, enabling Angular to render main content on the server and hydrate deferred blocks on the client after being triggered. The Angular team is actively prototyping this feature, with an early access program available for Devs building performance-critical applications.
Conclusion
In summary, implementing Server-Side Rendering (SSR) in Angular, along with Static Site Generation (SSG), Hydration and Event Replay, significantly improves the initial load performance of your Angular apps. This leads to a better user experience, especially on slower networks or low-budget devices, and enhances SEO and crawlability of your web app.
By following the steps and best practices outlined in this guide, you can achieve better load performance for your apps with minimal effort. The new Application Builder makes building and deploying very smooth.
Feel free to contact me for further questions or join our Performance Workshop to learn more about performance optimization for Angular apps.
References
- Why is Initial Load Performance so Important? by Alexander Thalhammer
- Angular 16 – official blog post by Minko Gechev
- Angular Update Guide to V17 incl. migrations by Alexander Thalhammer
- Angular 17’s Deferrable Views by Alexander Thalhammer
- Angular 18 – official blog post by Minko Gechev
- Angular’s after(Next)Render hooks by Netanel Basal
- Angular Event Replay blog post by Jatin Ramanathan, Tom Wilkinson
- Angular SSR Docker example by Alexander Thalhammer
This blog post was written by Alex Thalhammer. Follow me on GitHub, X or LinkedIn.
This content originally appeared on DEV Community and was authored by Alexander Thalhammer