Angular applications are client-side rendered by default — the browser downloads JavaScript, executes it, and builds the DOM. For SEO-critical pages, the initial white screen hurts both user experience and search rankings. Server-Side Rendering (SSR) with Angular Universal renders on the server, sends HTML immediately, then “hydrates” the client-side app — giving users instant visual content and search engines full HTML to index. Angular 17 made SSR a first-class feature with dramatically simplified setup.
SSR vs Static Prerendering vs CSR
| Approach | HTML Generated | Best For | Limitation |
|---|---|---|---|
| CSR (default) | Client browser on each visit | Authenticated dashboards, admin UIs | Poor SEO, slow FCP for first-time users |
| SSR (Universal) | Server on each request | Dynamic content needing SEO | Server infrastructure required |
| Static Prerendering | Build time — HTML files | Marketing pages, public content | No dynamic personalised content |
| Hybrid | Static for public, SSR for auth | Most production apps | More complex build pipeline |
SSR-Specific APIs
| API | Purpose |
|---|---|
isPlatformBrowser(platformId) |
Check if running in browser — guard browser-only APIs |
isPlatformServer(platformId) |
Check if running on Node.js server |
PLATFORM_ID token |
Inject to get current platform identifier |
TransferState |
Pass data from server render to client hydration |
withHttpTransferCache() |
Automatically transfer HTTP cache — prevents double fetches |
DOCUMENT token |
Safely access document in SSR context |
dist/app/server/server.mjs. Run it with node dist/app/server/server.mjs, deploy to any Node.js platform, or use behind nginx. The server renders Angular for each request using renderApplication().withHttpTransferCache() in provideClientHydration() to automatically serialise all HTTP responses made during SSR and transfer them to the client. Without this, the client Angular app re-makes every HTTP request that was already made during server-side rendering — doubling API calls. With transfer cache, the client uses already-fetched data from the HTML payload.window, document, localStorage, navigator, or browser globals. Any code accessing these directly throws a runtime error during SSR. Guard all browser-only code with isPlatformBrowser(inject(PLATFORM_ID)).Complete SSR Setup
# Add SSR to existing Angular project (Angular 17+)
ng add @angular/ssr
# Generates: app.config.server.ts, server.ts
# Updates: angular.json (server target), app.config.ts (hydration provider)
# Build client + server
ng build
# dist/app/browser/ — static files
# dist/app/server/ — Node.js server
# Run
node dist/app/server/server.mjs
// app.config.ts — enable hydration with transfer cache
import { provideClientHydration, withHttpTransferCache } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
provideClientHydration(withHttpTransferCache()), // prevents double API calls
],
};
// app.config.server.ts — server-side providers
import { mergeApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/platform-server';
const serverConfig: ApplicationConfig = {
providers: [provideServerRendering()],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
// ── Platform detection — guard browser-only code ─────────────────────────
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Injectable({ providedIn: 'root' })
export class StorageService {
private platformId = inject(PLATFORM_ID);
get(key: string): string | null {
if (!isPlatformBrowser(this.platformId)) return null;
return localStorage.getItem(key);
}
set(key: string, value: string): void {
if (!isPlatformBrowser(this.platformId)) return;
localStorage.setItem(key, value);
}
}
// Component — guard DOM library initialisation
@Component({ ... })
export class MapComponent implements AfterViewInit {
private platformId = inject(PLATFORM_ID);
@ViewChild('mapEl') mapEl!: ElementRef;
ngAfterViewInit(): void {
if (!isPlatformBrowser(this.platformId)) return; // skip on server
new ExternalMapLibrary(this.mapEl.nativeElement, { ... });
}
}
// ── Static prerendering ───────────────────────────────────────────────────
// angular.json — add prerender configuration
// "prerender": {
// "builder": "@angular/ssr:prerender",
// "options": { "routes": ["/", "/tasks", "/about"] }
// }
// ng run app:prerender → generates static HTML for each route
How It Works
Step 1 — The Server Renders Angular on Each Request
When a request arrives, the Express server calls engine.render() which bootstraps Angular in a server-side context, executes the component tree (running HTTP calls, resolvers, change detection), and serialises the DOM to an HTML string. This HTML is sent immediately — users see content without waiting for JavaScript. The HTML includes a script tag that bootstraps the client-side app.
Step 2 — Hydration Reuses Server-Rendered DOM
With provideClientHydration(), Angular’s client bootstrap reads the server-rendered DOM instead of clearing and rebuilding it. It finds each component’s host element, attaches change detection, and wires up event listeners — without re-rendering anything. This prevents the visual flash that would occur if the DOM were cleared and rebuilt.
Step 3 — Transfer Cache Prevents Double Data Fetching
During SSR, Angular makes HTTP calls to fetch data. Without transfer cache, the client makes those same calls again after hydration — doubling API load. withHttpTransferCache() serialises HTTP responses into the HTML as a JSON blob. The client reads this during hydration and uses those values for initial render, only fetching fresh data on subsequent navigations.
Step 4 — PLATFORM_ID Guards Browser-Only APIs
Angular’s SSR runs in Node.js with no browser globals. Any code accessing window, document, localStorage, or navigator must be guarded with isPlatformBrowser(inject(PLATFORM_ID)). Audit your application for: authentication tokens from localStorage, DOM manipulation in ngAfterViewInit, third-party libraries that use window.
Step 5 — Static Prerendering Generates HTML at Build Time
For routes whose content is the same for all users (landing pages, documentation), prerendering generates static HTML files at build time. The output is a folder of index.html files — one per route — served from a CDN with zero server processing. Combined with a service worker for caching, prerendered pages load nearly instantly for repeat visitors.
Common Mistakes
Mistake 1 — Accessing localStorage or window directly
❌ Wrong — crashes during SSR:
getTheme(): string {
return localStorage.getItem('theme') ?? 'light'; // ReferenceError on server
}
✅ Correct — guard with platform check:
getTheme(): string {
if (!isPlatformBrowser(inject(PLATFORM_ID))) return 'light';
return localStorage.getItem('theme') ?? 'light';
}
Mistake 2 — Not using withHttpTransferCache
❌ Wrong — API called during SSR AND again after hydration:
provideClientHydration() // tasks loaded twice per page view!
✅ Correct:
provideClientHydration(withHttpTransferCache())
Mistake 3 — Running third-party DOM libraries without platform check
❌ Wrong — chart library crashes on server:
ngAfterViewInit(): void {
new ChartLibrary(this.canvas.nativeElement, { ... }); // document undefined on server
}
✅ Correct:
ngAfterViewInit(): void {
if (!isPlatformBrowser(inject(PLATFORM_ID))) return;
new ChartLibrary(this.canvas.nativeElement, { ... });
}
Quick Reference
| Task | Code / Command |
|---|---|
| Add SSR | ng add @angular/ssr |
| Enable hydration | provideClientHydration() |
| Prevent double fetches | provideClientHydration(withHttpTransferCache()) |
| Check platform | isPlatformBrowser(inject(PLATFORM_ID)) |
| Server-only providers | app.config.server.ts with provideServerRendering() |
| Build SSR | ng build (produces browser + server bundles) |
| Run SSR server | node dist/app/server/server.mjs |
| Static prerender | ng run app:prerender |