Angular SSR and Hydration
Targets: Angular 22 (LTS 20 and 21 noted where different) · Verified: 2026-09 against https://angular.dev/guide/ssr, https://angular.dev/guide/hydration, https://angular.dev/guide/incremental-hydration
Server rendering is a per-route decision, not an application-wide switch: most apps want a few prerendered marketing routes, a few server-rendered dynamic ones, and everything behind the login client-rendered. Hydration then has one job — reuse the server's DOM and its data instead of throwing both away. Every SSR bug worth debugging comes from code that assumed a browser, or from data the client fetched twice.
1. Decide first
| Question | Default | Change when |
|---|---|---|
| Does this app need SSR at all? | No, for an authenticated internal app — CSR is simpler and there is nothing to index | Public content, SEO, link previews, or LCP on a slow network matters |
| Per route | RenderMode.Prerender for content that is the same for everyone |
Personalised or request-dependent → RenderMode.Server. Behind auth or highly interactive → RenderMode.Client |
outputMode |
"server" (default) — a Node server handles requests |
Every route is prerenderable → "static", deploy to a CDN with no server at all |
| Hydration | provideClientHydration() alone on v22 — incremental hydration and event replay are included |
Genuine mismatch you cannot fix → withNoIncrementalHydration() as a temporary measure with a ticket |
| Data fetched during SSR | Leave the transfer cache on; the client reuses the server's GET/HEAD responses |
Per-user data behind an Authorization header — it is excluded by default; opt in only with includeRequestsWithAuthHeaders and a deliberate decision |
| Browser APIs | afterNextRender({ read: … }) |
Needed before render → isPlatformBrowser(inject(PLATFORM_ID)), with a server-side fallback value |
| Below-the-fold islands | @defer (hydrate on viewport) |
Never interactive → @defer (hydrate never), which ships no JavaScript for it at all |
2. Setting it up
ng add @angular/ssr
This adds server.ts, app.config.server.ts and app.routes.server.ts, and sets outputMode in angular.json.
// app.routes.server.ts
import { RenderMode, type ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'products/:slug', renderMode: RenderMode.Prerender, async getPrerenderParams() {
const products: { slug: string }[] = await fetch('https://api.example.com/products').then((r) => r.json());
return products.map(({ slug }) => ({ slug })); // one object per page, every param keyed
} },
{ path: 'search', renderMode: RenderMode.Server, headers: { 'Cache-Control': 'public, max-age=60' } },
{ path: 'app/**', renderMode: RenderMode.Client },
];
app.config.server.ts wires it up with provideServerRendering(withRoutes(serverRoutes)) (plus withAppShell(AppShell) if a client-rendered area wants a prerendered shell). A ServerRoute also takes status and fallback (PrerenderFallback.Server by default, or Client / None) — the fallback decides what happens to a :slug that getPrerenderParams did not return, which is the difference between a working long tail and a wall of 404s. Route-mode choice, prerendering at scale, cache headers and deployment shape: references/rendering-modes.md.
3. Hydration
// app.config.ts
import { provideClientHydration } from '@angular/platform-browser';
providers: [provideClientHydration()];
On v22 that one call enables DOM reconciliation, the HTTP transfer cache and incremental hydration, which in turn enables event replay. So on v22: delete withIncrementalHydration() (deprecated, removal intended in v24) and delete withEventReplay() (redundant). On v20/21 both are still needed explicitly.
Then make islands hydrate on demand:
@defer (hydrate on viewport) { <app-reviews [productId]="id()" /> }
@placeholder { <div class="reviews-skeleton" style="min-height: 320px"></div> }
@defer (hydrate never) { <app-footer /> }
Triggers: hydrate on idle, on viewport, on interaction, on hover, on immediate, on timer(…), hydrate when <expr>, hydrate never. The block renders fully on the server either way; the trigger only decides when its JavaScript loads. hydrate never on genuinely static regions — footer, legal text, a rendered article body — is the cheapest win available, and every @defer needs a @placeholder with reserved height or you have traded JavaScript for layout shift.
4. Not fetching everything twice
The HTTP transfer cache serialises the server's GET and HEAD responses into the HTML so the client reuses them instead of repeating every request during hydration. It is on by default with provideClientHydration() and is the single biggest hydration-performance item.
Requests carrying Authorization, Proxy-Authorization or Cookie headers, and credentialed requests, are excluded — deliberately, because the cache sits in HTML a CDN may cache. That exclusion is the usual reason a team says "we turned on SSR and the client still loads everything again"; the other reasons are a client URL that differs from the server one, and POST requests, which need includePostRequests.
Tune with withHttpTransferCacheOptions({ includeHeaders, includePostRequests, filter, includeRequestsWithAuthHeaders }). Before enabling includeRequestsWithAuthHeaders, answer one question: can this route's HTML be cached by a CDN or a shared proxy? If yes, do not — you would serve one user's data to the next. withNoHttpTransferCache() disables the mechanism entirely, so every request runs twice; use it only to isolate a bug.
5. Browser-only code and mismatches
window, document, localStorage, navigator and IntersectionObserver do not exist during server rendering, and touching them at module scope or in a constructor crashes the render.
import { Component, ElementRef, PLATFORM_ID, afterNextRender, inject, signal } from '@angular/core';
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
@Component({ selector: 'app-widget', template: `<div [dir]="dir()">{{ width() }}px</div>` })
export class Widget {
private readonly el = inject(ElementRef<HTMLElement>);
private readonly doc = inject(DOCUMENT); // injectable — works on both platforms
protected readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
protected readonly width = signal(0);
protected readonly dir = signal(this.doc.documentElement.dir || 'ltr');
constructor() {
afterNextRender({ read: () => this.width.set(this.el.nativeElement.offsetWidth) });
}
}
afterNextRender and afterEveryRender never run on the server, which makes them the right home for measurement and third-party widget initialisation. inject(DOCUMENT) instead of the global. isPlatformBrowser only when the branch is needed before render, and then the server branch must still produce something renderable.
Mismatch errors (NG0500/NG0502) mean the client built different DOM than the server sent. Causes, in rough order of frequency: invalid HTML the browser silently repairs (a <div> inside a <p>, a <tr> outside a <tbody>), direct DOM manipulation, Date.now()/Math.random()/crypto.randomUUID() in a template, locale- or timezone-dependent formatting, and browser extensions. Fix the cause; ngSkipHydration is containment for a third-party widget, not a fix. Full triage table, transfer-cache tuning and how to test SSR: references/hydration-debugging.md.
6. Deliverable format
## Route map — table: route | render mode | why | cache header
## Config — app.routes.server.ts, app.config.server.ts, outputMode, angular.json changes
## Hydration — provideClientHydration features (and what was left off), hydrate trigger per island
## Data — which requests the transfer cache carries, which are excluded and what that costs
## Browser-only code — every window/document access and the guard it now has
## Verify — zero hydration warnings, and the before/after LCP number
Checklist
- Every route has an explicit
renderMode; no route falls through by accident -
getPrerenderParamsis present for every prerendered parameterised route, with a chosenfallback -
outputModematches reality ("static"only when no route needs a server) - On v22,
withIncrementalHydration()andwithEventReplay()are absent; on v20/21 they are present - The console shows no NG0500/NG0502 hydration warnings on any rendered route
- No
window/document/localStorageoutsideafterNextRenderor a platform guard - The transfer cache is on, any excluded request is a known accepted refetch, and no per-user data sits in a CDN-cacheable response
-
Cache-Controlset per server-rendered route; personalised routes are not publicly cacheable - The first-load number was measured before and after
Anti-patterns
- Turning SSR on for an internal app behind a login → nothing indexes it and there is no first-paint win; you bought a Node server to maintain.
withIncrementalHydration()orwithEventReplay()on v22 → both redundant;provideClientHydration()already includes them.ngSkipHydrationto silence a mismatch → it disables hydration for that subtree, so the DOM is destroyed and rebuilt. Fix the invalid markup or the non-deterministic value instead.includeRequestsWithAuthHeaders: trueon a CDN-cacheable route → one user's data embedded in HTML served to the next.isPlatformBrowserguards everywhere, ortypeof window !== 'undefined'→ most cases wantafterNextRender; a guard that renders nothing on the server defeats the point of SSR, and thetypeofcheck is untestable.RenderMode.Serverfor content identical for every visitor → prerender it and serve it from the CDN for free.- Shipping SSR without a measurement → the whole point was a number; if nobody took it, the added complexity is unjustified.
Go deeper
references/rendering-modes.md— choosing per route,getPrerenderParamsat scale, fallbacks, app shell,outputMode, CDN cache headers,server.tsandAngularNodeAppEnginereferences/hydration-debugging.md— NG0500/NG0502 causes and fixes, transfer-cache tuning,@defer (hydrate …)selection, testing SSRversion-notes.mdin../angular-development/references/— what is default in v20, v21 and v22- Siblings:
angular-performance(measuring the result),angular-architecture(whether to render on a server at all),angular-development,angular-testing