By default, Angular delivers the application as CSR (client-side rendering): the server sends an empty <app-root></app-root>, and the browser has to download, parse and run all the JavaScript — and then fetch the data too — before it shows anything useful. SSR (rendering on the server, on each request) and SSG (prerender, at build time) move the first render elsewhere: the user receives HTML with content straight away.
With @angular/ssr you don’t choose once for the whole application, but per route: the file app.routes.server.ts says for each path whether it is RenderMode.Prerender, RenderMode.Server or RenderMode.Client. This is called hybrid rendering. The server routes API appeared in v19 and has been stable since v20.
- Rendering in the browser (CSR), on the server (SSR) or at build time (prerender) is chosen per route, with
RenderModeinapp.routes.server.ts. - Prerender pages that look the same for everyone, SSR pages that are personalised or have fast-changing data, CSR for the areas behind login.
- SSR improves the first paint and what a crawler sees, but it adds a server to maintain and code that must also run on the server.
- Routes with parameters can be prerendered if you give them the list of values, with
getPrerenderParams. - CDN caching often matters more than server speed.
SSR is not a free performance switch. You gain LCP, SEO and correct previews on social networks; you pay with a Node server to operate, with code that must run without window, and with a window in which the page is visible but still doesn’t respond to clicks. The right decision is made per route, not per project.
Three rendering modes
CSR · Client | SSR · Server | SSG · Prerender | |
|---|---|---|---|
| The HTML appears | in the browser, after JS | on the server, on each request | at build time, once |
| The server, per request | serves a static file (the empty shell) | starts the application, asks for the data, renders | serves a static file |
| TTFB | small | includes the rendering and the APIs it calls | minimal, especially from a CDN |
| LCP | after JS + bootstrap + API request | as soon as the HTML arrives | as soon as the HTML arrives |
| SEO, previews | weak: depends on JS | excellent | excellent |
| Content per user | yes | yes | no — identical HTML for everyone |
| Hosting | any static hosting / CDN | a Node runtime (or another JS runtime) | any static hosting / CDN |
| Cost per request | ≈ 0 | CPU + memory for a full render | ≈ 0 |
What the server concretely does in Server mode: for each request, @angular/ssr creates a new platform, calls the bootstrap function from main.server.ts, lets the router navigate to the URL, waits for the application to become stable (all HTTP requests and PendingTasks finished), serialises the DOM into HTML and destroys the application. The practical consequence: the TTFB of an SSR page is at least as long as the slowest API called during rendering.
In a real hybrid application the modes coexist: the homepage and the marketing pages are prerendered, search is rendered on the server, and the account area runs only in the browser.
What happens on a request
Five moments matter, in the order the user experiences them:
- TTFB (time to first byte) — the first byte of the HTML response.
- FCP (first contentful paint) — the first text or the first image on screen.
- LCP (largest contentful paint) — the main element appears (the title, the product image). It’s the metric the user feels as “it has loaded”.
- Hydration — the JS has arrived, Angular takes over the existing HTML and attaches the listeners.
- Interactive — a click actually does something. From this moment INP matters (how quickly the page responds).
With SSR and SSG, between LCP and “interactive” there is a window in which the page looks ready, but the buttons don’t have a listener yet. On a modest phone, with a bundle of a few hundred KB, the window easily lasts 1–3 seconds. Run the simulation and compare the three modes on the same network.
SSR: TTFB includes the server’s work (which waits for the API), then the content appears immediately. Yellow band = content visible but without listeners until hydration.
Notice what does not change: main.js is the same size in all three modes. SSR doesn’t shrink the JavaScript, so interactivity comes at about the same time as with CSR — sometimes even a little later, because the HTML is bigger and hydration costs CPU too. The real gain is the LCP. To shrink the non-interactive window: less initial JS (lazy routes, @defer), incremental hydration and event replay, which doesn’t lose the clicks made in the window.
What a crawler sees
A crawler starts from the HTML it receives (what you see with View source, not in the Elements tab). With CSR, that HTML is identical for any URL. Googlebot can run JavaScript, but at a later stage and with limits; bots that generate previews in chats and social networks usually read only the initial HTML. There is one detail that is often ignored: the status code. A static server doesn’t know that /produse/9999 doesn’t exist, so it answers 200 — a “soft 404”.
HTTP/1.1 200 OK<!doctype html><html lang="en"><head> <title>Shop</title> <link rel="stylesheet" href="styles-5INURTSO.css"></head><body> <app-root></app-root> <script src="main-HG7KQWZB.js" type="module"></script></body></html>
- ✓Correct HTTP status200
- ✕page-specific <title>Shop
- ✕meta description / og:*missing
- ✕Visible text in the HTML0 words
- ✕Links <a href> to follow0
The same HTML for every URL: all the content appears only after JavaScript runs. Preview bots don’t run it; Googlebot does, but at a later stage and with limits.
The title and meta tags are set with the Title and Meta services from @angular/platform-browser. They work through DOCUMENT, so they also work on the server: whatever you set during rendering ends up in the HTML that is sent. For pages without data, the route’s title property is enough (or your own TitleStrategy, if you want a common format).
import { Component, computed, effect, inject, input } from '@angular/core';
import { httpResource } from '@angular/common/http';
import { Meta, Title } from '@angular/platform-browser';
interface Product { id: string; name: string; description: string; imageUrl: string }
@Component({
selector: 'app-product-page',
template: `
@if (product.hasValue()) {
<h1>{{ product.value().name }}</h1>
<p>{{ product.value().description }}</p>
}
`,
})
export default class ProductPage {
readonly id = input.required<string>(); // from the route produse/:id (withComponentInputBinding)
private readonly title = inject(Title);
private readonly meta = inject(Meta);
protected readonly product = httpResource<Product>(() => `/api/products/${this.id()}`);
constructor() {
// Runs on the server too: the tags end up in the HTML sent to the crawlers.
effect(() => {
if (!this.product.hasValue()) return;
const p = this.product.value();
this.title.setTitle(`${p.name} — Shop`);
this.meta.updateTag({ name: 'description', content: p.description });
this.meta.updateTag({ property: 'og:title', content: p.name });
this.meta.updateTag({ property: 'og:description', content: p.description });
this.meta.updateTag({ property: 'og:image', content: p.imageUrl });
});
}
}Put the logic in a service SeoService.update({ title, description, image, canonical }) and call it from the detail pages. For <link rel="canonical"> you need DOCUMENT — details on the Server-compatible code page.
Setup: what ng add generates
# new project, with SSR + prerender configured from the start
ng new magazin --ssr
# existing project: add @angular/ssr, express and the server files
ng add @angular/ssr
# production build → dist/magazin/browser + dist/magazin/server
ng build
# start the Node server (script added by ng add in package.json)
npm run serve:ssr:magazin # = node dist/magazin/server/server.mjsThe command adds the dependencies (@angular/ssr, express), four files and a few options in angular.json. It’s worth knowing what each one does, because you will modify them:
server.ts— the Express server. It serves the static files fromdist/…/browser, and gives the rest of the requests toAngularNodeAppEngine.handle(), which returns aResponse(ornullif the URL isn’t an Angular route). ThereqHandlerexport is used by the CLI:ng serverenders on the server too.main.server.ts— the bootstrap function the server calls on every render.app.config.server.ts—provideServerRendering(withRoutes(serverRoutes)), combined with the browser config throughmergeApplicationConfig.app.routes.server.ts— the render mode per route. By default, everything isPrerender.app.config.tsgetsprovideClientHydration(), so the browser reuses the HTML instead of rebuilding it.
import {
AngularNodeAppEngine,
createNodeRequestHandler,
isMainModule,
writeResponseToNodeResponse,
} from '@angular/ssr/node';
import express from 'express';
import { join } from 'node:path';
const browserDistFolder = join(import.meta.dirname, '../browser');
const app = express();
const angularApp = new AngularNodeAppEngine(); // one instance for the whole server
// 1. (optional) your APIs: app.get('/api/{*splat}', (req, res) => { ... });
// 2. Static files (JS/CSS/images have a hash in the name → one-year cache)
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false }));
// 3. Anything else: Angular decides — prerendered HTML, SSR or the CSR shell
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
.catch(next);
});
if (isMainModule(import.meta.url) || process.env['pm_id']) {
const port = process.env['PORT'] || 4000;
app.listen(port, (error) => {
if (error) throw error;
console.log(`Node Express server listening on http://localhost:${port}`);
});
}
// used by ng serve / ng build (and by some serverless platforms)
export const reqHandler = createNodeRequestHandler(app);A detail in server.ts: express.static has index: false, so not even / is served directly from disk. Every page goes through Angular, which applies the render mode, the headers and the status configured for the route.
To prevent SSRF, the server validates the Host header. From v22, an unknown host gets 400 Bad Request (before, the server fell back to CSR). ng add puts "allowedHosts": []: fill in your domains in angular.json (wildcards such as *.magazin.ro are accepted), in the options of AngularNodeAppEngine or in the environment variable NG_ALLOWED_HOSTS. Behind a load balancer, the X-Forwarded-* headers are ignored until you explicitly allow them with trustProxyHeaders. Details in Security.
If you have an older project whose server.ts uses CommonEngine: that API is deprecated since v22, its replacement being AngularNodeAppEngine (Node) or AngularAppEngine (runtimes based on the Web API Request/Response).
RenderMode per route
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
// The same for everyone, changes only at deploy → HTML generated at build time (SSG)
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'despre', renderMode: RenderMode.Prerender },
// Public, indexed, data that changes often → rendered on every request (SSR)
{ path: 'cautare', renderMode: RenderMode.Server },
// Behind the login: no SEO, personal HTML → browser only (CSR)
{ path: 'cont/**', renderMode: RenderMode.Client },
// The safety net for any Angular route not listed above
{ path: '**', renderMode: RenderMode.Server },
];The rules that matter in practice:
- Matching: an exact segment beats a parameter (
:id), which beats**. So**is the safety net, not a rule that “overrides”. - Two-way validation: every route in the router must match a server route, and every server route (except those ending in
/**) must exist in the router. Otherwise the build stops with an explicit message. - The path doesn’t start with “/” —
'/despre'is a build error. - What the browser receives:
Prerender→ the file generated at build time (with anETag);Server→ HTML rendered now;Client→index.csr.html, the empty shell, also served by your server.
- The HTML is identical for everyone and stable: you generate it once, at build time.
- Served as a static file (ideally from a CDN): minimal TTFB, almost zero cost per request.
- The data changes daily → you need a scheduled build (e.g. nightly in CI) that regenerates the pages.
- Thousands of pages add minutes to the build; measure the build time and the size of the deploy.
import { PrerenderFallback, RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: 'produse/:id',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const api = inject(CatalogApi); // synchronous, before the await
const items = await api.mostVisited(500);
return items.map((x) => ({ id: String(x.id) }));
},
fallback: PrerenderFallback.Server,
},
// ...
];Headers, status and redirects
Each ServerRoute can have headers and status. The exception: Prerender routes accept headers, but not status — the ServerRoutePrerender type excludes it, because the file is generated only once. The headers of a prerendered page apply only when it is served through the Node server, not from static hosting.
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
// Public HTML: the CDN can keep it for 60 s and serve the old version while it refreshes
path: 'cautare',
renderMode: RenderMode.Server,
headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' },
},
{
// permanent redirect: { path: 'oferte', redirectTo: 'promotii' } in app.routes.ts
path: 'oferte',
renderMode: RenderMode.Server,
status: 301, // without status: 302
},
{
// prerender: headers yes, status NO (the ServerRoutePrerender type excludes it)
path: 'despre',
renderMode: RenderMode.Prerender,
headers: { 'X-Frame-Options': 'DENY' },
},
{ path: '**', renderMode: RenderMode.Server },
];A redirectTo from the router becomes a real HTTP redirect on the server (302 by default; you can set status: 301, 303, 307 or 308), answered without rendering the application. Likewise, if a guard redirects during rendering (see Guards & resolvers), the final URL differs from the requested one and the server answers with a redirect. With outputMode: 'static', where there is no server, redirects are prerendered as “soft redirects” with a <meta http-equiv="refresh"> in the HTML.
Status and headers decided at runtime
When the status depends on data (the product doesn’t exist), you set it from the component through RESPONSE_INIT. For cookies, language or user agent you read the request through REQUEST. Both come from @angular/core and are null in the browser, at prerender and at build time — always handle the null case.
import { Component, RESPONSE_INIT, inject } from '@angular/core';
@Component({
selector: 'app-not-found',
template: `<h1>Page not found</h1>`,
})
export default class NotFound {
constructor() {
// RESPONSE_INIT is null in the browser, at prerender and at build → always check.
const response = inject(RESPONSE_INIT);
if (response) response.status = 404; // the crawler sees 404, not “200 with an error text”
}
}Prerender with parameters
Routes without parameters are discovered and prerendered automatically. For produse/:id, Angular can’t guess which IDs exist, so you give it a function getPrerenderParams. It runs at build time, in an injection context (you can use inject()), and returns a list of parameter objects → value. A Prerender route with parameters but without getPrerenderParams stops the build.
import { inject } from '@angular/core';
import { PrerenderFallback, RenderMode, ServerRoute } from '@angular/ssr';
import { ProductApi } from './products/product-api';
export const serverRoutes: ServerRoute[] = [
{
path: 'produse/:id',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const api = inject(ProductApi); // ✓ synchronous inject(), BEFORE any await
const ids = await api.topProductIds(500); // runs at BUILD time, not on request
return ids.map((id) => ({ id: String(id) })); // the values must be strings
},
// /produse/9999 was not prerendered → what do we do on request?
fallback: PrerenderFallback.Server, // default: SSR on request
},
{
path: 'blog/:slug/**', // parameters + catch-all
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
return [{ slug: 'ssr', '**': 'partea/1' }]; // → /blog/ssr/partea/1
},
},
{ path: '**', renderMode: RenderMode.Server },
];What happens to a URL that wasn’t generated at build time depends on fallback:
| PrerenderFallback | For /produse/9999 (not prerendered) | When to choose it |
|---|---|---|
Server (default) | rendered on request, on the server | a large catalogue: prerender the top N, the rest on request |
Client | receives the CSR shell, rendered in the browser | you want SEO only for the generated pages and don’t want SSR |
None | Angular doesn’t answer (the request goes on, usually a 404) | the list from the build is complete by definition |
inject() works only synchronously, at the beginning of the function. An inject() written after an await is no longer in an injection context and throws an error. Take all the dependencies in the first lines, then make the requests.
The cost is at build time: every URL is a full render. Ten thousand products mean ten thousand renders on every deploy and ten thousand files to upload. If the data changes daily, you also need a scheduled build that regenerates them.
Deploy, cache and costs
outputMode in angular.json decides what the build produces. With "server" (what ng add sets) you get dist/app/browser plus dist/app/server/server.mjs, and you need a Node process. With "static" you get only files: prerendered pages and the CSR shell, deployable on any CDN. A RenderMode.Server route in static mode is a build error.
{
"projects": {
"magazin": {
"architect": {
"build": {
"options": {
"outputMode": "static"
}
}
}
}
}
}The cache layers
| What | How | Watch out |
|---|---|---|
| JS, CSS, images with a hash | maxAge: '1y' in express.static / CDN | the names change on every build, so a long cache is safe |
| Prerendered HTML | static file, ETag → 304 Not Modified | changes only at deploy |
| Public SSR HTML | headers per route: public, s-maxage=60, stale-while-revalidate=300 | only for content identical for everyone |
| Personal SSR HTML | private, no-store | never public: the CDN would serve it to another user |
| Data HTTP during rendering | transfer cache: GET/HEAD responses end up in the HTML and are reused at hydration | requests with Authorization/Cookie are excluded by default |
The transfer cache is the reason why, in the timeline, SSR no longer makes the API request from the browser: the data fetched by HttpClient on the server is serialised into <script id="ng-state"> and read at startup. Its options (withHttpTransferCacheOptions, withNoHttpTransferCache) are on the Hydration page. Also: on the server, with the default fetch backend, each HTTP response is limited to 1 MB (error NG02825); the limit changes with provideServerRendering({ maxResponseBodySize }, …).
Operational cost
- CPU per request: each SSR render is a full bootstrap. Without a CDN cache, bot traffic is paid for in servers.
- TTFB tied to the backend: the server waits for stability, so a slow API slows down all your pages. Put timeouts in place and move secondary data into @defer (on the server, a block without a
hydratetrigger renders only the placeholder). - Memory shared between requests: the code is evaluated once per process, so module-level variables and
useValueproviders are common to all users. - One more system in production: logs, monitoring, scaling, Node version, cold start on serverless.
import { InjectionToken } from '@angular/core';
// ✗ Module-level state: on the server the module is evaluated ONCE,
// so the variable is shared by all requests (and all users).
let currentUser: string | null = null;
// ✗ useValue at application level: the same value until the server restarts.
export const RENDERED_AT = new InjectionToken<number>('RENDERED_AT');
const bad = { provide: RENDERED_AT, useValue: Date.now() };
// ✓ useFactory: runs on every render (each request has its own injector).
const good = { provide: RENDERED_AT, useFactory: () => Date.now() };
// ✓ Per-user state lives in services (instantiated per request), not in global variables.When YES, when NO
- Public pages that must be indexed and shared: products, articles, landing pages.
Prerenderif you can generate them at build time,Serverif not. - LCP translates into money (e-commerce, media), and some users have modest phones and networks.
- Stable content (documentation, blog, marketing):
Prerender+outputMode: 'static', with no server to maintain. - Public content with fresh data and heavy traffic:
Server+ a short cache at the CDN. - You need correct status codes (404, 301) for SEO.
- Applications behind the login (dashboard, admin, back office):
Client. Invest in a small bundle, lazy routes and @defer. - You have nobody to operate a Node server: stay with CSR or with
PrerenderandoutputMode: 'static'. - “The application is slow after loading”: SSR doesn’t help after hydration. The problem is in the JS or in change detection → Performance, Change detection.
- Routes built on browser APIs (maps, canvas, editors):
RenderMode.Clienton that route. - Personalised HTML with heavy traffic: don’t render per user. Prerender the common part and load the personal pieces in the browser.
Common pitfalls
Code that touches window, document or localStorage in the constructor, in ngOnInit or at module level crashes on the server. The solution: afterNextRender, the DOCUMENT token and separate service implementations for the server and the browser — all on the Server-compatible code page.
A let currentUser variable at module level, or a useValue computed at startup, lives as long as the Node process, not as long as the request. Put the state in services (instantiated per render) and use useFactory for values computed per request.
- Public cache on personalised HTML → one user sees another user’s page. On
Serverroutes with personal data useCache-Control: private, no-store. - Empty
allowedHostsin production → 400 on all pages (v22). Configure the domains orNG_ALLOWED_HOSTS. - “Soft 404”: the “not found” component answers 200. Set
status = 404throughRESPONSE_INIT. - Wrong SPA fallback on static hosting: with prerender,
index.htmlis the page of the/route.Clientroutes must fall back toindex.csr.html. - The application doesn’t become stable: a
PendingTaskwithout cleanup (or, with Zone.js, asetIntervalstarted in the Angular zone) keeps the rendering waiting, and the request never finishes. - A CDN that minifies the HTML and strips comments → hydration fails with NG0507. Angular’s comments are needed; disable the option on the CDN.
- “I added SSR and TTI stayed the same” — that’s normal: the JS is the same. Measure the LCP, not the TTI, and reduce the initial JS separately.
Cheat sheet
// ng new app --ssr | ng add @angular/ssr | ng build | node dist/app/server/server.mjs
// app.config.server.ts
provideServerRendering(withRoutes(serverRoutes));
provideServerRendering({ maxResponseBodySize: 5 * 1024 * 1024 }, withRoutes(serverRoutes)); // default 1 MB
// app.config.ts (browser)
provideClientHydration();
// app.routes.server.ts — import { RenderMode, PrerenderFallback, ServerRoute } from '@angular/ssr'
export const serverRoutes: ServerRoute[] = [
{ path: 'despre', renderMode: RenderMode.Prerender }, // HTML at build (SSG)
{
path: 'produse/:id',
renderMode: RenderMode.Prerender,
getPrerenderParams: async () => [{ id: '1' }, { id: '2' }], // runs at build
fallback: PrerenderFallback.Server, // | Client | None — default Server
},
{ path: 'cautare', renderMode: RenderMode.Server, headers: { 'Cache-Control': 'no-store' } },
{ path: 'cont/**', renderMode: RenderMode.Client }, // empty shell, rendered in the browser
{ path: '**', renderMode: RenderMode.Server }, // the safety net
];
// @angular/core — all null in the browser, at prerender and at build
inject(REQUEST); // Request: headers, cookies, URL
inject(RESPONSE_INIT); // ResponseInit: status / headers set dynamically
inject(REQUEST_CONTEXT); // the second argument of angularApp.handle(req, context)
// angular.json → architect.build.options
// "outputMode": "server" | "static" static = no Node: only Prerender + Client
// "security": { "allowedHosts": ["exemplu.ro"] } unknown host → 400 Bad Request (v22)