Before Angular 17, SSR was an afterthought. Angular Universal required a separate package, a separate configuration file, and a notoriously difficult setup that most teams either skipped entirely or abandoned after the first hydration error. Angular 17 changed everything by making SSR a first-class CLI feature — one command, fully integrated with the esbuild application builder. Angular 19 pushed it further with incremental hydration, letting you defer JavaScript execution to the exact moment a user interacts with a component. This guide covers the full picture: setup from scratch and from an existing project, how hydration actually works under the hood, incremental hydration with @defer triggers, Transfer State, prerendering, and the Lighthouse benchmarks that make the case to your team lead.
learn about Angular Development
Cumulative Layout Shift (CLS): The Complete Fix Guide for Angular Apps in 2025
SSR vs CSR vs Prerendering: Which Does Angular Need?
Before writing a line of code, it helps to understand what rendering strategy you are actually choosing — because each one optimises for a different constraint.
Client-Side Rendering (CSR)
The Angular default. The server sends a near-empty index.html with a <script> tag. The browser downloads the JavaScript bundle, parses it, executes it, and then renders the DOM. Nothing is visible until JavaScript finishes. LCP is directly tied to bundle parse time — typically 3–6 seconds on a mid-range mobile device. CSR is perfectly appropriate for authenticated dashboards, admin tools, or apps where SEO is irrelevant and users are on fast connections.
Server-Side Rendering (SSR)
The server renders full HTML for every incoming request, sends it to the browser, and the browser paints it immediately. The client-side Angular app then "hydrates" that HTML — attaching event listeners and taking over interactivity. LCP is drastically improved because the browser can paint before parsing any JavaScript. SSR is the right choice for marketing pages, blogs, e-commerce product pages, and any route that Google needs to crawl.
Prerendering (Static Site Generation)
HTML is generated once at build time and served as static files. Zero per-request server processing, maximum LCP, CDN-friendly. The limitation: content must be static or regenerated on each deploy. Works perfectly for blog posts, documentation, and landing pages. Angular's CLI supports prerendering natively alongside SSR.
Decision Matrix
- Dashboard / authenticated app — CSR, no SSR overhead needed
- Marketing or content site — SSR for dynamic content, prerendering for static pages
- E-commerce product pages — SSR (prices and inventory change frequently)
- Blog / documentation — Prerendering (content changes only on deploy)
- Mixed app (marketing + dashboard) — SSR for public routes, CSR for authenticated routes via route-level configuration
Setting Up Angular SSR in 2025 (Angular 17–19)
New Project with SSR Enabled
ng new my-app --ssr
The --ssr flag scaffolds everything: app.config.server.ts, server.ts, and wires provideClientHydration() into app.config.ts automatically. No separate package installation needed.
Adding SSR to an Existing Project
ng add @angular/ssr
This schematic modifies your existing project in place: adds @angular/ssr to your dependencies, creates server.ts, generates app.config.server.ts, and updates angular.json with the server build target. It does not break your existing CSR setup — SSR is additive.
What the Schematic Generates
After running ng add @angular/ssr, your project gains three key files:
server.ts — the Express server that handles incoming requests, renders Angular via CommonEngine, and sends the HTML response.
app.config.server.ts — server-specific providers that extend (not replace) your client app.config.ts.
Updated app.config.ts — provideClientHydration() is added to the providers array.
Complete app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import {
provideClientHydration,
withEventReplay,
} from '@angular/platform-browser';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
// withFetch() is required for SSR — Node.js does not have XMLHttpRequest
provideHttpClient(withFetch()),
// withEventReplay() captures user interactions before hydration completes
provideClientHydration(withEventReplay()),
],
};
The withFetch() call is non-optional. Angular's HttpClient defaults to XMLHttpRequest in the browser, but Node.js does not ship XHR. Using withFetch() switches the underlying transport to the Fetch API, which works in both environments. Forgetting this is one of the most common SSR setup errors.
Understanding Hydration: How Angular Reuses the Server DOM
Without Hydration
Pre-Angular 16, SSR sent HTML to the browser but Angular had no awareness of it. On bootstrap, Angular would destroy the entire server-rendered DOM tree and rebuild it from scratch using JavaScript. This produced a visible flash — the server HTML would disappear momentarily as Angular replaced it — and completely wasted the server-rendering work from a performance perspective.
With provideClientHydration()
Hydration changes the bootstrap process fundamentally. During SSR, Angular serialises its component tree structure into the rendered HTML as special comment nodes and attributes. When the client-side Angular bootstraps, it reads these annotations and walks the existing DOM tree instead of creating new elements. Event listeners attach to existing nodes. The result: no DOM replacement, no layout flash, and the browser's paint from the server HTML is preserved throughout the JavaScript execution phase.
Event Replay with withEventReplay()
There is a window between when the browser paints the server HTML and when Angular finishes hydrating. If a user clicks a button during that window — before Angular has attached click handlers — the event is lost without event replay. withEventReplay() installs a global event capture layer that records all user interactions before hydration completes, then replays them in order once Angular is ready. For perceived interactivity, this is critical: users on slow connections should not lose their clicks.
Inspecting Hydration with Angular DevTools
Angular DevTools (Chrome extension) has a dedicated Hydration panel. After enabling SSR, open DevTools, navigate to the Angular tab, and select Hydration. Each component in the tree is marked with its hydration status — hydrated, skipped, or mismatch. This is the fastest way to identify which components are causing hydration warnings in the console.
Skipping Hydration for Specific Components
Some third-party components directly manipulate the DOM in ways that are fundamentally incompatible with hydration. For these, Angular provides an escape hatch:
<app-legacy-chart ngSkipHydration></app-legacy-chart>
Components marked with ngSkipHydration are excluded from the hydration process — Angular destroys and re-renders them client-side as it did pre-hydration. Use sparingly; each skipped component re-introduces the flash and performance cost for that subtree.
Hydration Mismatches: Causes and Fixes
A hydration mismatch means the DOM Angular receives from the server does not match the DOM Angular would generate on the client. Angular logs these as NG0500 errors in the console during development and falls back to client-side re-rendering for the affected component.
Common Causes
Browser-only API access — calling document.querySelector, window.innerWidth, or localStorage.getItem during component initialisation. The server does not have these APIs; any code path that reaches them during SSR throws or returns undefined, producing different output than the browser would.
Non-deterministic values — using Date.now(), Math.random(), or new Date() in a template renders a different string on the server (at request time) than on the client (at hydration time, milliseconds later).
Third-party DOM mutation — analytics scripts, chat widgets, and A/B testing libraries that inject or modify DOM elements before Angular hydrates will create nodes the server HTML does not contain.
Fix 1: isPlatformBrowser Guard
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, inject, OnInit } from '@angular/core';
import { Component } from '@angular/core';
@Component({ selector: 'app-analytics', template: '' })
export class AnalyticsComponent implements OnInit {
private readonly platformId = inject(PLATFORM_ID);
ngOnInit(): void {
if (isPlatformBrowser(this.platformId)) {
// Safe: only runs in the browser, never during SSR
const stored = localStorage.getItem('user-preferences');
this.applyPreferences(stored);
}
}
private applyPreferences(data: string | null): void {
// browser-only logic
}
}
Fix 2: afterNextRender for DOM Access
import { afterNextRender, ElementRef, inject } from '@angular/core';
import { Component } from '@angular/core';
@Component({
selector: 'app-canvas-chart',
template: '<canvas #chartCanvas></canvas>',
})
export class CanvasChartComponent {
private readonly el = inject(ElementRef);
constructor() {
// afterNextRender only fires in the browser, after the first render cycle
// Safe for DOM measurement, canvas initialisation, third-party chart libraries
afterNextRender(() => {
const canvas = this.el.nativeElement.querySelector('canvas');
this.initChart(canvas);
});
}
private initChart(canvas: HTMLCanvasElement): void {
// chart library initialisation
}
}
Fix 3: @defer as the Nuclear Option
When a component is deeply coupled to browser APIs and cannot be refactored quickly, wrapping it in a @defer block excludes it from SSR entirely. The server renders the @placeholder content; the real component is downloaded and rendered client-side only.
@defer (on idle) {
<app-third-party-map [coordinates]="coords()" />
} @placeholder {
<div class="map-placeholder" aria-label="Map loading">
<img src="/assets/img/map-static.jpg" alt="Location map" />
</div>
}
Incremental Hydration with @defer (Angular 19)
Standard hydration hydrates the entire application on bootstrap — every component, every event listener, regardless of whether the user will ever interact with that part of the page. On content-heavy pages (long articles, comment sections, product detail pages with many widgets), this means downloading and executing JavaScript for components that may be below the fold for the majority of users.
Incremental hydration, introduced as a developer preview in Angular 19, separates two concerns that were previously coupled: when to download JavaScript and when to hydrate the component. Components inside a @defer block are still server-rendered as full HTML (users see them immediately), but their JavaScript is not downloaded or executed until a trigger fires.
Enabling Incremental Hydration
import {
provideClientHydration,
withEventReplay,
withIncrementalHydration,
} from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
withEventReplay(),
withIncrementalHydration()
),
],
};
Usage with Hydration Triggers
<!-- Hydrate when the comments section enters the viewport -->
@defer (on viewport; hydrate on viewport) {
<app-comments [postId]="postId()" />
} @placeholder {
<div class="comments-skeleton" aria-label="Comments loading">
<div class="skeleton-line"></div>
<div class="skeleton-line short"></div>
</div>
}
<!-- Hydrate only when the user interacts (click, focus, keydown) -->
@defer (on idle; hydrate on interaction) {
<app-share-widget [url]="pageUrl()" />
} @placeholder {
<button class="share-btn-placeholder">Share this article</button>
}
<!-- Hydrate on hover — good for dropdowns and tooltips -->
@defer (on idle; hydrate on hover) {
<app-mega-menu />
} @placeholder {
<nav class="nav-placeholder">Menu</nav>
}
<!-- Never hydrate — static server HTML only, no JS ever downloaded -->
@defer (hydrate never) {
<app-footer-legal />
}
Available Hydration Triggers
| Trigger | When it fires |
|---|---|
hydrate on idle |
Browser is idle (requestIdleCallback) |
hydrate on viewport |
Component enters the viewport |
hydrate on interaction |
User clicks, focuses, or presses a key within the block |
hydrate on hover |
User moves the mouse over the block |
hydrate when condition |
A signal or expression becomes truthy |
hydrate never |
Hydration is permanently deferred — server HTML only |
Real-World Use Cases
Comment sections — server-rendered HTML for SEO, hydrated on viewport entry. Users who never scroll to comments never download the comment component's JavaScript.
Social share widgets — hydrated on interaction. The share button is visible and styled from SSR; the share API logic loads only when clicked.
Chat or support widgets — hydrated on idle, ensuring they do not compete with above-the-fold hydration.
Analytics dashboards embedded in pages — hydrated on viewport, so chart library code (often 100KB+) only loads if the user scrolls to the chart section.
Transfer State: Sharing Server Data to the Client
The Double-Fetch Problem
Without Transfer State, a component that fetches data in ngOnInit will trigger two HTTP requests: one during SSR (to populate the server-rendered HTML) and one during client-side hydration (because Angular does not know the data was already fetched). Users see the correct server HTML immediately, but then the component re-fetches, potentially showing a loading state or reflashing content.
Automatic Transfer State with withFetch()
Angular 17 introduced automatic HTTP Transfer State. When you configure provideHttpClient(withFetch()), Angular automatically caches all HTTP responses made during SSR and embeds them in the rendered HTML as a serialised state. On the client, HttpClient checks this cache before making a network request — if the response is already there, it returns the cached value immediately and marks it as consumed. No manual code required for standard HTTP calls.
This is why withFetch() is doubly important: it is required for SSR to work at all in Node.js, and it is the prerequisite for automatic Transfer State.
Manual Transfer State for Custom Server Data
For data that does not come from HttpClient — environment variables, feature flags, CMS configuration fetched via a direct database call on the server — you can use the TransferState API manually:
import {
TransferState,
makeStateKey,
inject,
PLATFORM_ID,
} from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { Component, OnInit, signal } from '@angular/core';
const CONFIG_KEY = makeStateKey<Record<string, string>>('app-config');
@Component({
selector: 'app-root',
template: '<!-- app content -->',
})
export class AppComponent implements OnInit {
private readonly transferState = inject(TransferState);
private readonly platformId = inject(PLATFORM_ID);
readonly config = signal<Record<string, string>>({});
ngOnInit(): void {
if (isPlatformBrowser(this.platformId)) {
// Client: read from transfer state, do not fetch again
const cached = this.transferState.get(CONFIG_KEY, {});
this.config.set(cached);
this.transferState.remove(CONFIG_KEY);
} else {
// Server: fetch config, store in transfer state for the client
const serverConfig = this.loadServerConfig();
this.transferState.set(CONFIG_KEY, serverConfig);
this.config.set(serverConfig);
}
}
private loadServerConfig(): Record<string, string> {
// Server-side: read from environment, database, or config file
return { theme: 'dark', featureFlag: 'true' };
}
}
Prerendering: When SSR Is Overkill
For content that does not change between requests — blog posts, documentation pages, marketing landing pages — prerendering generates the HTML once at build time and serves it as a static file. No Node.js server required at runtime, perfect CDN caching, and the best possible TTFB.
Configuring Prerendering in angular.json
{
"projects": {
"my-app": {
"architect": {
"build": {
"configurations": {
"production": {
"server": "src/main.server.ts",
"prerender": {
"routesFile": "prerender-routes.txt",
"discoverRoutes": true
}
}
}
}
}
}
}
}
Prerender Routes File
/
/about
/blogs/angular-ssr-hydration-performance-guide-2025
/blogs/angular-19-zoneless-defer-blocks-performance-guide-2025
/contact
For dynamic routes (blog slugs, product IDs), discoverRoutes: true instructs the CLI to crawl your router config and discover parameterised routes automatically. For routes that require database data to resolve parameters, provide a manual routesFile listing each concrete URL.
When both SSR and prerendering are configured, Angular prerenders the listed routes at build time and falls back to on-demand SSR for any route not in the prerender list — the best of both worlds for mixed content sites.
For a deeper look at building truly static Angular content sites, see the related guide on Angular 19 Zoneless, Defer Blocks, and Performance.
Core Web Vitals Impact: Before and After SSR
The business case for Angular SSR comes down to three metrics: LCP, CLS, and TTFB. Here is what to expect in practice, along with the Lighthouse workflow for measuring each stage.
LCP (Largest Contentful Paint)
This is where SSR delivers the most dramatic improvement. A typical Angular CSR application on a mid-range mobile device on a 4G connection sees LCP in the 3.5–6 second range — the browser must download the JavaScript bundle, parse it, execute it, and then render the DOM before anything is painted. With SSR and hydration, the server sends fully rendered HTML, the browser paints immediately, and LCP typically drops to 1.0–1.8 seconds for the same page and network conditions.
TTFB (Time to First Byte)
SSR adds a small amount of server processing time — Angular rendering the component tree and generating HTML typically adds 50–200 milliseconds of TTFB compared to serving a static index.html. On balance this is strongly net positive: the extra 100ms of TTFB is vastly outweighed by eliminating 3–4 seconds of JavaScript parse time. However, for very high-traffic pages, consider prerendering to eliminate TTFB overhead entirely.
CLS (Cumulative Layout Shift)
Without hydration, Angular's client bootstrap destroys and replaces the server HTML — triggering layout shift as elements briefly disappear and reappear. With provideClientHydration(), Angular reuses existing DOM nodes, and CLS from DOM replacement drops to zero. Residual CLS typically comes from images without explicit width/height attributes or dynamically loaded fonts.
INP (Interaction to Next Paint)
SSR does not directly improve INP — that metric depends on JavaScript execution during interaction, not initial render. The indirect benefit comes from @defer: by deferring hydration of non-critical components, less JavaScript executes on the main thread during initial load, leaving more budget for interaction handlers. For the most significant INP improvements, combine SSR with zoneless change detection.
Lighthouse Measurement Workflow
# Step 1: baseline CSR measurement
# Build without SSR, run Lighthouse in Chrome DevTools (mobile, 4G throttling)
# Record LCP, CLS, TTFB
# Step 2: enable SSR, rebuild and re-measure
ng build --configuration production
# Run Lighthouse on the same URL with SSR active
# Compare LCP improvement
# Step 3: add incremental hydration, rebuild and re-measure
# Wrap below-fold components in @defer (hydrate on viewport) blocks
# Rebuild, re-run Lighthouse
# Look for reduced Total Blocking Time alongside LCP
In Chrome DevTools Performance tab, SSR success is visible as "Parse HTML" being the first significant item in the flame chart — before any JavaScript execution. In a CSR build, the flame chart starts with "Evaluate Script" and "Compile Code" before any rendering begins.
Common SSR Pitfalls and Fixes
localStorage and sessionStorage access — these APIs do not exist in Node.js. Any direct access crashes the SSR render. Guard with isPlatformBrowser() or move to a service that provides a safe fallback.
document.querySelector in constructors — Angular constructors run during the module initialisation phase, before the DOM is available in either environment. Move any DOM access to ngAfterViewInit and guard with afterNextRender() to ensure it only runs client-side.
Angular Material animations — some animation modules reference document or window during initialisation. Provide NoopAnimationsModule (or provideNoopAnimations()) on the server to disable animations during SSR, while keeping provideAnimationsAsync() on the client.
Third-party scripts requiring window — analytics, chat widgets, and payment SDKs that call window.analytics or window.Stripe will crash SSR. Wrap their Angular wrapper components in @defer (on idle) so they load exclusively client-side.
Memory leaks in server-side code — unlike a browser where each page load is a fresh process, the SSR server is a long-running Node.js process. Observables that never complete, global state mutated per request, and setInterval calls without cleanup accumulate across requests. Ensure all subscriptions are managed with takeUntilDestroyed() and avoid module-level mutable state.
Route guards that call browser APIs — CanActivate guards that read cookies via document.cookie or check window.location need platform guards. Consider using Angular's inject(REQUEST) token (available in SSR context) for cookie access on the server side.
SSR and Design Systems: What Changes
Angular SSR compatibility requirements ripple into component library design. If you maintain a design system alongside your application, there are specific considerations for SSR readiness.
Constructor DOM access — any component that reads nativeElement in the constructor or in ngOnInit before the first render cycle will mismatch during hydration. Design system components must defer DOM reads to ngAfterViewInit or afterNextRender().
Icon rendering — SVG sprite icons and inline SVG render correctly during SSR and are included in the server HTML. Icon fonts (Material Icons loaded via Google Fonts) render as empty boxes until the font loads client-side, causing CLS. Prefer inline SVG or SVG sprites for any icon used above the fold.
Skeleton loaders and @loading blocks — SSR changes how skeleton states should work. In a CSR app, a skeleton loader displays during the client fetch. In an SSR app, the server already has the data — show the real content from SSR, and use @defer's @placeholder block for components that load lazily client-side. Misapplying skeleton loaders can actually worsen perceived performance with SSR by flashing a skeleton that was never needed.
Design tokens and CSS custom properties — CSS custom properties defined at the :root level in a global stylesheet are included in the server-sent HTML and work perfectly with SSR. There is no hydration concern with CSS — only JavaScript-driven styles applied via [style.color] bindings can cause mismatches if they depend on browser APIs.
Component testing for SSR — design system components should be tested in a Node.js environment using renderApplication() or renderModule() to catch SSR breakage before it reaches consuming applications. Add SSR smoke tests to your design system CI pipeline.
For a broader framework on design system architecture that scales with SSR requirements, see the related post on Design System Maturity Model and Framework 2025.
Performance Checklist for Angular SSR
ng add @angular/ssris your starting point — do not manually configure Angular Universal; the built-in CLI integration handles esbuild, hydration, and Transfer State automatically.Add
withFetch()toprovideHttpClient()— required for Node.js compatibility and for automatic HTTP Transfer State; forgetting it breaks SSR entirely.Add
withEventReplay()toprovideClientHydration()— captures user interactions during the hydration window; critical for perceived interactivity on slow connections.Enable
withIncrementalHydration()for content-heavy pages — wrap below-fold components in@defer (hydrate on viewport)blocks to defer JavaScript execution.Guard all browser-only APIs with
isPlatformBrowser()— audit every use oflocalStorage,sessionStorage,document,window, andnavigatorin your codebase.Move DOM access to
afterNextRender()— never accessnativeElementin constructors orngOnInit; useafterNextRender()for all DOM measurements and third-party library initialisation.Use
ngSkipHydrationsparingly — each skipped component re-introduces flash and layout shift; prefer fixing the root cause over skipping.Prerender static routes — blog posts, landing pages, and documentation routes should be prerendered at build time; reserve on-demand SSR for dynamic routes.
Serve explicit image dimensions —
widthandheightattributes on<img>elements prevent layout shift when the server HTML is displayed before stylesheets load.Test SSR in CI — add a server-side render smoke test (
renderApplication()) for each major route; catchNG0500hydration errors and Node.js crashes before they reach production.Profile with Chrome DevTools Performance tab — look for "Parse HTML" as the first significant event in the flame chart; if "Evaluate Script" comes first, SSR is not serving HTML correctly.
Measure Core Web Vitals on real devices — Lighthouse lab scores are directional; use Chrome User Experience Report (CrUX) data or field data from your analytics to track LCP, CLS, and INP improvements after enabling SSR.
SSR Is Now the Angular Default — For Good Reason
The evolution from Angular Universal's painful manual setup to ng new my-app --ssr represents one of the most significant developer experience improvements in Angular's history. But the change is not cosmetic — the underlying architecture is fundamentally better. Hydration that reuses server DOM, event replay that captures pre-hydration interactions, incremental hydration that defers JavaScript to the exact moment it is needed: these are the building blocks of applications that score green on Core Web Vitals and load fast on every network.
LCP is a Google search ranking signal. Users on slow mobile networks — which account for the majority of global web traffic — benefit most from SSR. An Angular application with SSR, hydration, and incremental @defer blocks is not just faster in benchmarks; it is meaningfully more usable for the users who need it most.
If you are on an existing Angular application still using CSR, the migration path is one command: ng add @angular/ssr. Add withEventReplay(), audit your components for browser-only API access, and run Lighthouse before and after. The LCP improvement alone typically justifies the work within the first page you optimise.
Start with ng add @angular/ssr this sprint. Your Lighthouse scores — and your users — will notice immediately.
