---
name: angular-ssr
description: Design and build server-side rendering, prerendering and hydration for Angular — per-route `RenderMode` in `app.routes.server.ts`, `provideServerRendering(withRoutes(...))`, `getPrerenderParams`, `provideClientHydration()` with incremental hydration and `@defer (hydrate on …)`, the HTTP transfer cache, browser-only code with `afterNextRender`, `outputMode`, `AngularNodeAppEngine` and CDN caching. Use this whenever the user runs `ng add @angular/ssr`, edits `app.routes.server.ts`, `app.config.server.ts` or `server.ts`, asks whether a route should be prerendered or server-rendered, hits NG0500/NG0502 hydration mismatches, `window is not defined` or `document is not defined` on the server, sees the client refetch everything the server already loaded, or asks about SEO, app shell or streaming. Also use it when a slow first paint on a public page turns out to be a rendering-mode decision.
metadata:
  technology: Angular
  type: architecture
---

# 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

```bash
ng add @angular/ssr
```

This adds `server.ts`, `app.config.server.ts` and `app.routes.server.ts`, and sets `outputMode` in `angular.json`.

```ts
// 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

```ts
// 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:

```html
@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.

```ts
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
- [ ] `getPrerenderParams` is present for every prerendered parameterised route, with a chosen `fallback`
- [ ] `outputMode` matches reality (`"static"` only when no route needs a server)
- [ ] On v22, `withIncrementalHydration()` and `withEventReplay()` are absent; on v20/21 they are present
- [ ] The console shows no NG0500/NG0502 hydration warnings on any rendered route
- [ ] No `window`/`document`/`localStorage` outside `afterNextRender` or 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-Control` set 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()` or `withEventReplay()` on v22** → both redundant; `provideClientHydration()` already includes them.
- **`ngSkipHydration` to 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: true` on a CDN-cacheable route** → one user's data embedded in HTML served to the next.
- **`isPlatformBrowser` guards everywhere, or `typeof window !== 'undefined'`** → most cases want `afterNextRender`; a guard that renders nothing on the server defeats the point of SSR, and the `typeof` check is untestable.
- **`RenderMode.Server` for 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, `getPrerenderParams` at scale, fallbacks, app shell, `outputMode`, CDN cache headers, `server.ts` and `AngularNodeAppEngine`
- `references/hydration-debugging.md` — NG0500/NG0502 causes and fixes, transfer-cache tuning, `@defer (hydrate …)` selection, testing SSR
- `version-notes.md` in `../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`
