Server rendering improves first paint and SEO, but introduces platform-specific bugs that need expert handling.
Angular's SSR support lives in @angular/ssr. Create an app with ng new --ssr or add it with ng add @angular/ssr.
Render modes per route are configured in app.routes.server.ts with ServerRoute entries: RenderMode.Prerender (static HTML at build time, with getPrerenderParams for dynamic segments), RenderMode.Server (rendered per request) or RenderMode.Client (browser only). They are registered with provideServerRendering(withRoutes(serverRoutes)) in the server config.
Hydration reuses the server-rendered DOM instead of re-creating it. It is enabled with provideClientHydration(); add withEventReplay() to capture clicks that happen before the code loads and replay them, and withIncrementalHydration() to keep parts of the page dehydrated until needed: @defer (hydrate on viewport) or hydrate on interaction renders the content on the server but only downloads and hydrates its code when triggered.
HttpClient requests made during SSR are cached into the HTML and reused on the client (the HTTP transfer cache), so data isn't fetched twice.
Pitfalls:
- No
window,documentlayout orlocalStorageon the server. UseafterNextRenderor checkisPlatformBrowser(inject(PLATFORM_ID)), and injectDOCUMENTinstead of the global. - Hydration requires the DOM to match: direct DOM manipulation or invalid HTML nesting causes errors (
ngSkipHydrationis an escape hatch). - Long-running timers or never-completing work keep the server waiting for the app to become stable.
- Server-side, services are per request, but module-level variables are shared between users.
Example
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender }, // static at build time
{ path: 'products/:id', renderMode: RenderMode.Prerender,
async getPrerenderParams() { return [{ id: '1' }, { id: '2' }]; } },
{ path: 'account/**', renderMode: RenderMode.Client }, // browser only
{ path: '**', renderMode: RenderMode.Server }, // per request
];
// app.config.server.ts: provideServerRendering(withRoutes(serverRoutes))
// app.config.ts: provideClientHydration(withEventReplay(), withIncrementalHydration())
// theme.ts - browser APIs used safely
import { Component, afterNextRender, signal } from '@angular/core';
@Component({
selector: 'app-theme',
template: `<p>Theme: {{ theme() }}</p>
@defer (hydrate on viewport) { <p>Hydrated lazily</p> }`,
})
export class Theme {
protected theme = signal('light'); // same on server and first client render
constructor() {
afterNextRender(() => this.theme.set(localStorage.getItem('theme') ?? 'light'));
}
}Remember
- Per-route
RenderMode: Prerender, Server or Client provideClientHydration()with event replay and incremental hydration (@defer (hydrate on ...))- Browser-only code goes in
afterNextRenderor behindisPlatformBrowser - HttpClient responses are transferred from server to client automatically
Watch out for
- Reading
localStorageduring rendering, which crashes on the server or causes a hydration mismatch.