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

Updated on Nov 9th, 2024 for Hybrid Rendering and Incremental Hydration in Angular v19.

This comprehensive post includes a quick introduction to SSR, a detailed setup guide and several best practices with Angular v18 or even v19 (v19 to be released on Nov 18th, 2024), enhancing the initial load performance and thus the user experience of modern Angular applications. While we do not recommend updating production apps to V19 at the time of updating this post, most of the presented SSR features are already stable in Angular v18. The new Incremental Hydration and Hybrid Rendering features of v19 can easily be added later on. Nevertheless, if you want to use Material and/or CDK with SSR, you need at least v18.

In any case, make sure you have at least 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 v17):

ng new your-fancy-app-name

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

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.

Important note: For the use of SSG you don't need a node.js / express server. You can still ship your application from nginx or Apache.

Hydration (preview in v16, stable since v17)

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 v17.

BTW, it's super easy to enable Hydration in Angular ๐Ÿ’ง

export const appConfig: ApplicationConfig = {
    providers: [
        provideClientHydration(), // use v16 hydration
    ],
};

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

@NgModule({
    providers: [provideClientHydration()],
})
export class AppModule {}

Event Replay (in preview since v18, but battle-proven by Google)

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 v18 event replay
        ),
    ],
};

Note: At the time of writing this feature is still in Developer Preview, so use it cautiously. However, I believe it's ready for production implementation.

Hybrid Rendering (in Developer Preview since v19)

Angular v19 will introduce Hybrid Rendering to meet modern web demands following this RFC in the Angular GitHup repo. It allows to provide additional route information for the server. Details like rendering modes and response headers will provide finer control for SSR.

We can now select the page rendering mode per route:

  • SSR: The page renders on the server during a request (best for dynamic content that updates quickly)
  • SSG: The page renders during build time and is served as a static asset (best for UX
    & performance)
  • CSR: The page renders in the browser (best for user based content)

To do this we need to add a serverConfig in app.config.server.ts looking like this:

/* src/app/app.config.server.ts */
// imports [...]

const serverAppConfig: ApplicationConfig = {
    providers: [provideServerRendering(), provideServerRoutesConfig(serverRoutes)],
};

export const serverConfig = mergeApplicationConfig(appConfig, serverAppConfig);

This will be used in our main.server.ts instead of the client appConfig:

/* src/main.server.ts */
// imports [...]

const bootstrap = () => bootstrapApplication(AppComponent, serverConfig);

export default bootstrap;

Next, we can specify the renderMode for each serverRoute:

/* src/app/app.routes.server.ts */
// imports [...]

export const serverRoutes: ServerRoute[] = [
    { path: "ssr", renderMode: RenderMode.Server },
    { path: "ssg", renderMode: RenderMode.Prerender },
    { path: "csr", renderMode: RenderMode.Client },
];

Note: The same routes need to be used in app.routes.ts to become fully functional.

Additionally, some intelligent features like server side 301 redirects or 404 not found errors can be added to the server config.

/* src/app/app.routes.server.ts */
// imports [...]

export const serverRoutes: ServerRoute[] = [
    // [...],
    { path: "redirect", renderMode: RenderMode.Server, status: 301 },
    {
        path: "error",
        renderMode: RenderMode.Server,
        status: 404,
        headers: {
            "Cache-Control": "no-cache",
        },
    },
    { path: "**", renderMode: RenderMode.Server },
];

If you want play around with this, check out this v19-ssr demo by ma man Matthieu Riegler.

Note: At the time of writing this feature is still in Developer Preview, so use it cautiously as the API may still change.

Incremental Hydration (in experimental since v19)

Partial Hydration, now called Incremental 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 the initial load but also runtime performance by loading less JavaScript upfront. It builds upon the fabulous defer API, in which we all fell in love since Angular v17. It's enabling Angular to render the HTML content on the server and hydrate deferred blocks on the client after they have been triggered to do so.

The Angular team (special thanks goes out to Jessica Janiuk) completed the RFC in the Angular GitHup repo and is now actively prototyping this feature, with an experimental release in v19 for all performance-critical applications out there ๐Ÿฅณ

To test it simply add withIncrementalHydration() to your app.config.ts

import { provideClientHydration, withEventReplay, withIncrementalHydration } from "@angular/platform-browser";

export const appConfig: ApplicationConfig = {
    providers: [
        // [...]
        provideClientHydration(withEventReplay(), withIncrementalHydration()),
    ],
};

Next, you can set a Hydration Trigger by choosing either:

  • hydrate on with a trigger (same as for defer, see full list of built-in triggers here)
  • hydrate when with a boolean flag or a boolean function or
  • hydrate never component will get rendered but not hydrated

hydrate on

@defer (on viewport; prefetch on idle; hydrate on hover) {
<app-deferred-hydration />
}

The component's static and SSRed HTML will be rendered before coming into the viewport, the corresponding JS bundle will be prefetched on idle (like PreloadingStrategy in the Router) and then finally the JS (all the interactivity, like event handlers) will be hydrated into the browser on hover. This is in stark contrast to the default full hydration, where all the components are hydrated at once.

hydrate when

@defer (hydrate when isUserLoggedIn) {
<app-deferred-hydration />
}

The component will be rendered on immediate, i.e. after bootstrapping the App and initializing the parent component, but it will only be hydrated once isUserLoggedIn is true.

hydrate never

@defer (on viewport; hydrate never) {
<app-deferred-hydration />
}

The component will be rendered on viewport, but it will never be hydrated - so no event handlers will be attached.

No demo yet

I'm still hoping the Angular team will release their demo with the cool hydration heads up display.

In the meantime, make sure to play around with this fun-tastic feature ๐Ÿ˜

Feedback

Since this feature is still in experimental make sure to help further improving it by providing useful feedback to the RFC in the Angular GitHup repo.

Build

Since Angular v17 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).

{
    "server": {
        "builder": "@angular-devkit/build-angular:server",
        "options": {
            "outputPath": "dist/your-fancy-app-name/server",
            "main": "server.ts",
            "tsConfig": "tsconfig.server.json"
        }
    }
}

This will, of course, be added automatically once you run

ng add @angular/ssr

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.

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 v18 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.

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 MARKDOWN_HASH6253f585155c17c2ee424234ce1f3494MARKDOWNHASH. 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 v17 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, 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!

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,
    },
];

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 MARKDOWN_HASHeacf331f0ffc35d4b482f1d15a887d3bMARKDOWNHASH when offline. For more information, consult the Angular Docs_ and read this response on Stack Overflow.

Outlook

To see which features are already production-ready, check out this helpful feature table:

To see whatโ€™s planned for upcoming Angular releases, check the roadmap:

Workshops

If you want to deep dive into Angular, we offer a variety of workshops โ€“ both in English and German.

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 even smoother.

Feel free to join our Performance Workshop ๐Ÿš€ to learn more about performance optimization for Angular apps.

References