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 }
];
ModeCostFits
PrerenderOne render at build time per routeMarketing pages, docs, any route with no per-user data
ServerA render per requestPersonalised pages that still need SEO or fast first paint
ClientNothing on the serverAuthenticated dashboards behind a login
Incremental hydrationPer-block hydrationPages 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);
    });
  }
}
SymptomCauseFix
Hydration mismatch warningMarkup differs between server and browserMove the difference into afterNextRender or transfer it via state
Flicker on loadContent rendered then replacedSet the value on the server too, or render it client-only behind @defer
Duplicate data requestsServer fetch and browser fetchTransfer the data with TransferState or rely on the resource's transfer support
A request goes to localhostRelative URL resolved on the serverUse an absolute base URL in the server build
Time-dependent text differsRendered at two different momentsCompute once on the server and transfer the value
Storage error during prerenderDirect localStorage accessGuard 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

ModelOutputTrade-off
Static hostingbrowser/ onlyCheapest and fastest; no per-request rendering
Node serverbrowser/ + server/Full SSR and dynamic routes; you operate a process
Serverless or edge functionServer bundle per requestScales to zero, but cold starts and size limits
CDN with prerendered routesbrowser/ plus static HTMLBest of both for mostly-static sites
ContainerNode image with the build outputFull 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.

Change detection, zoneless and performance Setting up a workspace with the Angular CLI

Last refreshed 2026-09-18.