Skip to content

SSR & Rendering

Angular can render an application in three different places, and modern Angular lets you mix all of them in the same project:

ModeWhen HTML is producedBest for
CSR (Client-Side Rendering)In the browser, at runtimePrivate dashboards, internal tools, admin panels
SSR (Server-Side Rendering)On the server, per requestContent that depends on the user or on fresh data
SSG / Prerender (Static Site Generation)At build timeMarketing pages, docs, blogs, catalogues

Choosing correctly is mostly a business decision, not a technical one. The rule of thumb:

  • If the page must be indexed by search engines or shared on social media → SSR or SSG.
  • If the content is identical for every user and changes rarely → SSG.
  • If the content is personalised or changes per request → SSR.
  • If the page is behind a login and SEO is irrelevant → CSR (cheaper and simpler).

With CSR the browser downloads an almost empty index.html, then the JS bundle, then bootstraps Angular, then fetches data. The user stares at a blank screen during the whole chain. With SSR the first HTTP response already contains meaningful HTML, so FCP (First Contentful Paint) and LCP (Largest Contentful Paint) improve dramatically.

Most crawlers execute JavaScript today, but they do it with a delay and a budget. Social media scrapers (Slack, WhatsApp, LinkedIn, X) do not execute JavaScript at all. Without SSR/SSG your Open Graph previews will be empty. See SEO & Meta Tags.

  • It does not make your bundle smaller.
  • It does not make TTI (Time To Interactive) better by itself — it can even make it worse if hydration is not configured properly.
  • It does not remove the need for good change detection and lazy loading.

Terminal window
ng new my-app --ssr
Terminal window
ng add @angular/ssr

This adds:

  • server.ts — the Node/Express entry point.
  • app.config.server.ts — server-specific providers.
  • A server build target in angular.json.
  • provideClientHydration() in app.config.ts.

Minimal app.config.ts:

import { ApplicationConfig } from '@angular/core';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideHttpClient, withFetch } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(withEventReplay()),
provideHttpClient(withFetch()),
],
};

Hybrid rendering with route-level rendering modes

Section titled “Hybrid rendering with route-level rendering modes”

Since Angular 19 you can declare the rendering mode per route in a app.routes.server.ts file. This is the single most useful SSR feature: you no longer have to choose one mode for the whole application.

import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
// Static marketing page, generated at build time
{ path: '', renderMode: RenderMode.Prerender },
// Product pages: prerender a known set of ids
{
path: 'products/:id',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const ids = await fetch('https://api.example.com/product-ids').then(r => r.json());
return ids.map((id: string) => ({ id }));
},
},
// Personalised, rendered per request
{ path: 'account/**', renderMode: RenderMode.Server },
// Behind login, no SEO value: keep it client-side
{ path: 'admin/**', renderMode: RenderMode.Client },
// Everything else
{ path: '**', renderMode: RenderMode.Server },
];

Register it in app.config.server.ts:

import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
import { provideServerRendering, withRoutes } from '@angular/ssr';
import { appConfig } from './app.config';
import { serverRoutes } from './app.routes.server';
const serverConfig: ApplicationConfig = {
providers: [provideServerRendering(withRoutes(serverRoutes))],
};
export const config = mergeApplicationConfig(appConfig, serverConfig);
Route characteristicsRecommended mode
Same HTML for everyone, changes on deployPrerender
Same HTML for everyone, changes oftenServer + HTTP cache / CDN
Depends on cookies, headers or sessionServer
Requires localStorage, geolocation, canvasClient
Authenticated area, no SEOClient

Without TransferState, every HTTP request executed during server rendering is executed again in the browser after hydration. You pay twice and the user may see a flicker.

Angular solves it automatically when you use provideClientHydration() + provideHttpClient(withFetch()): GET and HEAD responses are serialised into the HTML and replayed on the client.

For non-HTTP state, use TransferState explicitly:

import { inject, makeStateKey, TransferState } from '@angular/core';
const CONFIG_KEY = makeStateKey<AppConfig>('app-config');
export function loadConfig(): AppConfig {
const transferState = inject(TransferState);
if (transferState.hasKey(CONFIG_KEY)) {
return transferState.get(CONFIG_KEY, DEFAULT_CONFIG);
}
const config = readConfigFromDisk();
transferState.set(CONFIG_KEY, config);
return config;
}

Terminal window
# Build browser + server bundles
ng build
# Run the SSR server
node dist/my-app/server/server.mjs

Verify that SSR is really working:

Terminal window
# Should contain your rendered markup, not an empty <app-root></app-root>
curl -s http://localhost:4000/ | head -50

Checklist:

  • curl returns real content, not just <app-root></app-root>.
  • No NG0500/NG0505 hydration warnings in the browser console.
  • The Network tab shows no duplicated API calls after load.
  • Meta and Open Graph tags are present in the raw HTML.