DEV Community

Alexander Thalhammer
Alexander Thalhammer

Posted on • Originally published at angulararchitects.io

Complete Guide for Server-Side Rendering (SSR) in Angular

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
Enter fullscreen mode Exit fullscreen mode

For existing projects simply run the ng add command (since Angular 17):

ng add @angular/ssr
Enter fullscreen mode Exit fullscreen mode

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
    ],
};
Enter fullscreen mode Exit fullscreen mode

If you're still using NgModules (for reasons), it becomes:

@NgModule({
    providers: [provideClientHydration()],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
        ),
    ],
};
Enter fullscreen mode Exit fullscreen mode

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"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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!");
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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 },
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

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!");
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

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 or pnpm dev:ssr or pnpm 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 and server builds.
  • Environments: Ensure environment variables are correctly set up for both browser and server 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 />
Enter fullscreen mode Exit fullscreen mode

Alternatively, you can set ngSkipHydration as a host binding.

@Component({
    host: { ngSkipHydration: "true" },
})
class DryComponent {}
Enter fullscreen mode Exit fullscreen mode

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())],
};
Enter fullscreen mode Exit fullscreen mode

If you're still using NgModules (for reasons), this becomes:

@NgModule({
    providers: [provideHttpClient(withFetch())],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

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
            }),
        ),
    ],
};
Enter fullscreen mode Exit fullscreen mode

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." });
    }
}
Enter fullscreen mode Exit fullscreen mode

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`);
    }
}
Enter fullscreen mode Exit fullscreen mode
export const environment = {
    production: false,
    baseUrl: "http://localhost:4200", // <== provide base URL for each env
};
Enter fullscreen mode Exit fullscreen mode

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:

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,
    },
];
Enter fullscreen mode Exit fullscreen mode

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 />
}
Enter fullscreen mode Exit fullscreen mode

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

This blog post was written by Alex Thalhammer. Follow me on GitHub, X or LinkedIn.

Top comments (0)