Server-Side Rendering — Angular Universal, Hydration, and Static Prerendering

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
Note: Angular 17+ SSR uses a new application builder that generates both a browser build and a server build. The server build produces a Node.js application at 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().
Tip: Use 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.
Warning: Server-side rendered code runs in Node.js — there is no 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

🧠 Test Yourself

An Angular SSR app fetches tasks from the API during server render. After hydration, the client makes the same API call again. What option in provideClientHydration() prevents this double fetch?