Server rendering, hydration and deployment
Turn on rendering, control it per route, avoid the platform traps, and choose a hosting model that matches how you build.
Enabling rendering and hydration
ng add @angular/ssr # adds server.ts, app.config.server.ts and build targets
ng build # emits browser/ and server/ output
ng run billing-portal:serve # runs the Node server on the built output
ng serve # SSR in development, with live reload
# prerender specific routes at build time
ng build --prerender// app.routes.server.ts — rendering mode per route, not per application
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender },
// Public, mostly static: render at build time.
{ path: 'pricing', renderMode: RenderMode.Prerender },
{ path: 'docs/:slug', renderMode: RenderMode.Prerender },
// Personalised: render on each request.
{ path: 'dashboard', renderMode: RenderMode.Server },
// Interactive only: ship the HTML shell and let the browser do the work.
{ path: 'reports/**', renderMode: RenderMode.Client },
{ path: '**', renderMode: RenderMode.Server }
];| Mode | Cost | Fits |
|---|---|---|
Prerender | One render at build time per route | Marketing pages, docs, any route with no per-user data |
Server | A render per request | Personalised pages that still need SEO or fast first paint |
Client | Nothing on the server | Authenticated dashboards behind a login |
| Incremental hydration | Per-block hydration | Pages where only part of the content is interactive |
⚠️
Any component that touches
window, document, localStorage or a measurement API during construction will break the server render. Move that work into afterNextRender, or check the platform first — the error often appears only in the production prerender step.The traps that only appear under rendering
import { Component, inject, signal, PLATFORM_ID, TransferState, makeStateKey, afterNextRender } from '@angular/core';
import { isPlatformBrowser, isPlatformServer } from '@angular/common';
const BUILT_AT = makeStateKey<string>('builtAt');
@Component({ selector: 'app-footer', template: `<footer>Built {{ builtAt() }}</footer>` })
export class FooterComponent {
private readonly platform = inject(PLATFORM_ID);
private readonly transfer = inject(TransferState);
readonly builtAt = signal('');
constructor() {
if (isPlatformServer(this.platform)) {
// Compute once on the server and hand the value to the browser through
// the serialised transfer state, so both renders agree.
this.transfer.set(BUILT_AT, new Date().toISOString());
this.builtAt.set(this.transfer.get(BUILT_AT, ''));
}
afterNextRender(() => {
// Browser only: the server never runs this.
this.builtAt.set(this.transfer.get(BUILT_AT, new Date().toISOString()));
console.log('viewport', window.innerWidth);
});
}
}| Symptom | Cause | Fix |
|---|---|---|
| Hydration mismatch warning | Markup differs between server and browser | Move the difference into afterNextRender or transfer it via state |
| Flicker on load | Content rendered then replaced | Set the value on the server too, or render it client-only behind @defer |
| Duplicate data requests | Server fetch and browser fetch | Transfer the data with TransferState or rely on the resource's transfer support |
A request goes to localhost | Relative URL resolved on the server | Use an absolute base URL in the server build |
| Time-dependent text differs | Rendered at two different moments | Compute once on the server and transfer the value |
| Storage error during prerender | Direct localStorage access | Guard with isPlatformBrowser or read it in afterNextRender |
// A service that must not touch browser storage during a server render
import { Injectable, inject, PLATFORM_ID } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
@Injectable({ providedIn: 'root' })
export class PreferenceStore {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
read(key: string): string | null {
return this.isBrowser ? localStorage.getItem(key) : null;
}
write(key: string, value: string): void {
if (this.isBrowser) localStorage.setItem(key, value);
}
}Deployment models
| Model | Output | Trade-off |
|---|---|---|
| Static hosting | browser/ only | Cheapest and fastest; no per-request rendering |
| Node server | browser/ + server/ | Full SSR and dynamic routes; you operate a process |
| Serverless or edge function | Server bundle per request | Scales to zero, but cold starts and size limits |
| CDN with prerendered routes | browser/ plus static HTML | Best of both for mostly-static sites |
| Container | Node image with the build output | Full control, most operational work |
# A small production image for an SSR build
ng build --configuration production
docker build -t billing-portal:latest .
# Typical multi-stage Dockerfile shape:
# FROM node:22-alpine AS build
# WORKDIR /app
# COPY package*.json ./
# RUN npm ci
# COPY . .
# RUN npm run build
#
# FROM node:22-alpine
# WORKDIR /app
# COPY --from=build /app/dist ./dist
# COPY --from=build /app/package*.json ./
# RUN npm ci --omit=dev
# ENV PORT=4000
# CMD ["node", "dist/billing-portal/server/server.mjs"]- Static hosting with prerendering is the right default. Turn on server rendering only for the routes that genuinely need it.
- If you serve the browser bundle from a CDN and the server render from a function, cache the prerendered HTML — otherwise every visitor pays for a render that produces the same bytes.
- Set
provideClientHydration()with event replay so clicks that happen before hydration completes are not lost. - Check the transfer-state payload size: large serialised caches inflate every response and undo the benefit of rendering on the server.
// app.config.ts — hydration with event replay
import { provideClientHydration, withEventReplay, withIncrementalHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withEventReplay(), withIncrementalHydration())
]
};FAQ
Do I need SSR for SEO?
Only if the content is public and must be indexed. A prerendered static build gets you the same crawlable HTML with far less infrastructure. Put server rendering on the routes that are genuinely per-request, and leave the rest prerendered.
Why do I get a hydration mismatch only in production?
Usually time or randomness: a timestamp, a random id, or a locale difference. Development serves both renders from the same process and often masks it. Find the value that differs, compute it on the server, and pass it to the browser through transfer state.
Related
Change detection, zoneless and performance Setting up a workspace with the Angular CLI
Last refreshed 2026-09-18.