Angular Advanced #6 SSR — Angular Universal and Hydration
Until now, every app we built across the Angular series has been CSR (Client-Side Rendering). You may remember answering No to the Server-Side Rendering (SSR)? question when creating a project in Basics #2 ng new. The result of that choice is the standard SPA we’ve worked with — receiving an empty HTML shell first, and the screen drawing only after the JavaScript bundle is downloaded.
For most internal systems and dashboards, that’s enough. For a public website, the story changes. Search engines need to index the content, the first screen needs to appear quickly so users don’t leave, and social share previews need the right title and image. SSR (Server-Side Rendering) is the answer to those needs.
In this post we’ll cover the integrated SSR model in modern Angular (Angular 17+). We’ll touch on the old name Angular Universal, Hydration, TransferState, and Pre-rendering (SSG) — all in one go.
CSR / SSR / SSG — three rendering models #
First, let’s pin terms. Even for the same Angular app, depending on when and where the screen is built, we get three flavors.
- CSR (Client-Side Rendering) — The browser receives an empty HTML, downloads the JS bundle, runs it, and only then draws the screen. The way we’ve been doing things.
- SSR (Server-Side Rendering) — Every request runs the app on the server to produce complete HTML and sends it back. The user sees the first screen immediately, and JS layers interactivity on top.
- SSG (Static Site Generation, Pre-rendering) — Every page is rendered into HTML at build time. When a request comes, the server simply serves the static file.
CSR : request → empty HTML + JS → drawn in the browser
SSR : request → server generates HTML → sends it → browser hydrates
SSG : (HTML generated at build time) → request → static HTML served immediatelyThe important thing — you don’t have to pick one. Modern Angular integrates all three so that you can mix them per route within the same build. A blog body page can be SSG, a user dashboard SSR, an admin screen with heavy interactivity CSR — they coexist naturally inside one app.
Where Angular Universal stands #
Search “Angular SSR” and you’ll often see the name Angular Universal. That’s the old name. From around 2017, separate packages (@nguniversal/*) handled SSR, but from Angular 17, SSR was integrated into the framework itself and the names and package layout were tidied up.
~Angular 16 : separate packages like @nguniversal/express-engine
Angular 17+ : unified into a single @angular/ssr package (ng new --ssr)For new projects, knowing @angular/ssr is enough. That said, you’ll still run into @nguniversal/... in company codebases, so it’s good to know “this is the old name.” The internals share the same lineage.
Enabling SSR #
For a new project, a single --ssr flag is enough.
ng new my-app --ssrIf you already created the project with No to the SSR question, you can add it later with ng add.
ng add @angular/ssrRunning this command produces the following changes.
src/
├── main.server.ts ← server entry point (added)
├── app/
│ ├── app.config.ts ← client config (modified)
│ └── app.config.server.ts ← server config (added)
└── server.ts ← Express server (added)
angular.json ← server build target added
package.json ← serve:ssr:my-app script addedserver.ts is the heart. It’s a small Express-based Node server that takes incoming requests, renders the Angular app on the server, and responds with HTML. It’s a regular Express app you can edit directly to add middleware or extra routes.
provideServerRendering / provideClientHydration
#
Modern Angular with standalone components configures itself as a collection of function calls. When SSR is on, client and server config files come as a pair.
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideClientHydration(),
provideHttpClient(withFetch()),
],
};import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
import { appConfig } from './app.config';
const serverConfig: ApplicationConfig = {
providers: [provideServerRendering()],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);What each function does is right there in the name.
provideServerRendering()— Registers the tokens needed to render the app on a server (Node).provideClientHydration()— Turns on hydration so the browser hydrates the DOM it received from the server. Without this, even with SSR enabled, the client will redraw the DOM from scratch — the most common SSR setup mistake.provideHttpClient(withFetch())— On the server you can’t useXMLHttpRequest, so thefetchbackend is used. Effectively required in an SSR environment.
What is Hydration #
The word Hydration that just appeared deserves a brief side note. By analogy:
The server draws dry HTML and sends it; the client pours the water of JavaScript on top to make it living components.
Hydration is the process where the client takes the static DOM the server drew, attaches event handlers and connects signals/bindings on top of that DOM.
Before Angular 16, even with SSR enabled, when the client arrived it would redraw the entire DOM from scratch (destructive hydration). The screen would flicker once, and from the user’s side it felt awkward — “why is it redrawing?”
In Angular 16, non-destructive hydration stabilized officially and that problem went away. The client doesn’t touch the DOM the server drew; it only adds event listeners on top. No flicker, and time-to-interactive (TTI) after first paint is faster too.
Just turning on provideClientHydration() activates this mode.
Date.now() — a value that changes every time — sneaks into the server output, the client will recompute it differently and a hydration mismatch can happen. Split such dynamic values into a client-only component, or apply the time display via [ngClass] or a directive only on the client.TransferState — don’t fetch the same data twice #
Once you turn SSR on, a subtle problem shows up. The server fetched data via HttpClient to render HTML, but the client fetches the same API again during hydration.
If the server hands the response it already has down to the client along with the HTML, and the client uses that value at first, one fetch is enough. Angular’s TransferState API solves this cleanly.
The simplest path is to use provideClientHydration(withHttpTransferCacheOptions(...)) together with withFetch(). Turn this option on, and GET responses fetched during SSR are serialized into the HTML and sent down with it; the client looks at that cache and skips the re-fetch.
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withFetch()),
provideClientHydration(
withHttpTransferCacheOptions({
includePostRequests: false,
filter: (req) => !req.url.includes('/admin'),
}),
),
],
};When you want to pass key-value pairs by hand, you can inject the TransferState token and handle it explicitly.
import { inject, Injectable, makeStateKey, TransferState } from '@angular/core';
import { HttpClient } from '@angular/common/http';
const POSTS_KEY = makeStateKey<Post[]>('posts');
@Injectable({ providedIn: 'root' })
export class PostsService {
private http = inject(HttpClient);
private state = inject(TransferState);
async loadPosts(): Promise<Post[]> {
// On the client's first run, reuse the value the server stashed
const cached = this.state.get(POSTS_KEY, null);
if (cached) {
this.state.remove(POSTS_KEY);
return cached;
}
const posts = await firstValueFrom(this.http.get<Post[]>('/api/posts'));
this.state.set(POSTS_KEY, posts); // when set on the server side, it's serialized into the HTML
return posts;
}
}The implementation differs but both share the same goal — “hand the data the server fetched alongside the HTML so the client doesn’t fetch it again.”
Things that don’t work on the server #
The moment SSR is on, our components run twice. Once on the Node server, once in the browser. Node has no window, no document, no localStorage. Sticking code like the following at the top of a component or in ngOnInit blows up server rendering on the spot.
ngOnInit() {
const theme = localStorage.getItem('theme'); // ReferenceError: localStorage is not defined
document.body.classList.add(theme ?? 'light');
}The fix is a platform guard. Angular provides a token that tells you which platform is currently running.
import { Component, inject, OnInit, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Component({
selector: 'app-theme',
standalone: true,
template: `<button (click)="toggle()">Toggle theme</button>`,
})
export class ThemeComponent implements OnInit {
private platformId = inject(PLATFORM_ID);
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
const theme = localStorage.getItem('theme');
document.body.classList.add(theme ?? 'light');
}
}
toggle() {
if (!isPlatformBrowser(this.platformId)) return;
document.body.classList.toggle('dark');
}
}Wrap with isPlatformBrowser and the server skips the block. setTimeout, IntersectionObserver, chart libraries, map libraries — think of every browser-dependent piece of code as belonging inside this guard.
window immediately on import. Such libraries should be wrapped with dynamic import like import('chart.js'), and loaded only when isPlatformBrowser is true. If a static import contains a window reference, the server build itself can fail.Pre-rendering / SSG #
For pages whose content barely changes — blog posts, documentation pages — having the server build the same HTML on every request feels wasteful. For these, pre-rendering at build time is overwhelmingly more efficient.
@angular/ssr treats Pre-rendering (SSG) as a first-class citizen. Define routes in app.routes.ts and specify the rendering strategy alongside each route.
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender }, // home — render HTML at build time
{ path: 'about', renderMode: RenderMode.Prerender },
{
path: 'posts/:slug',
renderMode: RenderMode.Prerender,
getPrerenderParams: async () => {
const posts = await loadAllPosts();
return posts.map((p) => ({ slug: p.slug }));
},
},
{ path: 'dashboard/**', renderMode: RenderMode.Server }, // dashboard — SSR per request
{ path: 'admin/**', renderMode: RenderMode.Client }, // admin — CSR
];There are three RenderModes.
Prerender— Generate HTML at build time (SSG)Server— Render on the server per request (SSR)Client— Render only on the client (CSR)
Being able to mix strategies per route is a key advantage of modern Angular SSR. Within the same build and the same codebase, you choose the strategy that fits each page’s nature.
ng buildAfter the build, dist/my-app/browser/ holds the client assets and prerendered static HTML; dist/my-app/server/ holds the Node bundle for SSR.
Deployment #
Where you put the output of ng build depends on whether any route uses Server mode.
If everything is Prerender — dist/my-app/browser/ is all you need. Plain static hosting works, so Vercel, Netlify, Cloudflare Pages, or even ordinary static hosting like S3 + CloudFront, anywhere is fine.
If you have any Server mode route — You need a runtime to execute Angular on the server. There are roughly two paths.
- Node server (Express) — Run the
server.mjsproduced byng builddirectly. AWS EC2, GCP, Render, Railway, Fly.io, or wrap it in a Docker image — anywhere. Most traditional, and you can plug in any middleware freely. - Serverless / edge runtimes — Environments like Vercel, Netlify Functions, or Cloudflare Workers that run a function per request. Cold starts are short, and you can respond from a global edge. Angular CLI is standardizing this with
ng add @angular/ssr --server-routingand choosing a builder to attach a deployment adapter.
npm run serve:ssr:my-appMake a habit of always verifying the local SSR behavior with this command before deploying. ng serve (dev mode) doesn’t always have SSR on, so server-only code issues can slip through.
Wrap-up #
In this post we did a full sweep of modern Angular SSR.
- CSR / SSR / SSG — Angular 17+’s integrated model lets you mix rendering strategies per route inside one build
- Angular Universal is the old name. Now it’s tidied up as
@angular/ssr provideClientHydration()+ non-destructive hydration for flicker-free first paint- TransferState /
withHttpTransferCacheOptionsremoves duplicate server/client calls isPlatformBrowserguards browser-only APIs likewindowanddocumentRenderModepicks Prerender / Server / Client per route
Turning SSR on isn’t hard, but it requires the mindset shift “this code runs on the server too, and on the client too.” That No you skipped past in Basics #2 — now you can answer Yes with confidence.
The next post — “Angular Advanced #7 Performance tuning” — covers OnPush change detection, track, lazy loading, bundle analysis — techniques to polish a real production Angular app for speed.