An Angular application that wants to be readable immediately, even by a search engine or a slow phone, needs two things: the server must hand it ready-written HTML, and the browser, once it has received it, must not throw it away to start over. The first job is server-side rendering (SSR), the second is hydration. Two ingredients of modern Angular make all this readable today: standalone components, which declare by themselves what they use, and signals, values that know who reads them. After the chapter on TypeScript, here you build a small shop and look at the HTML that comes out of the server.
Everything runs in a test project of mine, with Angular 22.2.6 (the latest version on the npm registry on 13 November 2026), TypeScript 6.0.3 (the range that the Angular 22 compiler declares it requires) and Node.js 24.11. A note of honesty: the @angular/core 22.2.6 package declares Node ^24.15 in its engines field, so my machine is below the minimum and what follows is a trial, not a confirmation of compatibility. The same code, compiled with 21.2.26 (the LTS line at the moment), gives the same HTML structure, with a different ng-version and a different hash. There is no browser and no server listening: the rendering is done by the renderApplication function inside Node, and the network is replaced by a function that answers from memory.
Signals: values that know who reads them
A signal is a value container that keeps track of who reads it. A computed is a value derived from other signals: it recalculates only when needed and only if something it depends on has changed. The listing counts how many times the derivation function runs, and that counter is the only honest way to see what happens.
import { computed, signal, untracked } from '@angular/core';
const price = signal(100);
const qty = signal(2);
let runs = 0;
const total = computed(() => {
runs++;
return price() * qty();
});
console.log('after declaration:', runs);
console.log('first read:', total(), 'runs:', runs);
console.log('second read:', total(), 'runs:', runs);
qty.set(3);
console.log('after qty.set(3):', 'runs:', runs);
console.log('read:', total(), 'runs:', runs);
qty.set(3);
console.log('same value:', total(), 'runs:', runs);
const label = computed(() => `${untracked(qty)} pieces at ${price()}`);
console.log(label());
qty.set(9);
console.log(label());
price.set(110);
console.log(label());
// Output:
// after declaration: 0
// first read: 200 runs: 1
// second read: 200 runs: 1
// after qty.set(3): runs: 1
// read: 300 runs: 2
// same value: 300 runs: 2
// 3 pieces at 100
// 3 pieces at 100
// 9 pieces at 110
After the declaration the runs are zero: the computed is lazy, it does not calculate until someone reads it. The second read returns 200 without running the function again (the value is cached), and changing qty causes nothing until someone reads again: the runs stay at 1 and rise to 2 at the next read. Setting the same value (qty.set(3) with 3 already inside) invalidates nothing. This behaviour, "lazily evaluated and memoized", is the one described in the official guide to signals. The last part shows untracked: reading qty inside the computed does not create a dependency, and indeed label() stays "3 pieces at 100" after qty.set(9) and updates only when price changes, showing 9 pieces. It is more of a trap than a feature: use it when you really want to ignore a value, not to "make a component stop updating".
A standalone component with the new control flow
Before standalone components every component had to be declared in an NgModule, and to understand what a template could use you had to read the module. Today the component lists in its decorator what it uses (imports) and modules are not needed. The following listing defines a child component that formats a price, a component that shows the list and the root component.
import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
export interface Product { id: number; name: string; cents: number }
@Component({
selector: 'app-price',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<span class="price">{{ euros() }}</span>`,
})
export class PriceTag {
readonly cents = input.required<number>();
protected readonly euros = computed(() => (this.cents() / 100).toFixed(2) + ' EUR');
}
@Component({
selector: 'app-products',
imports: [PriceTag],
template: `
<h2>Products</h2>
@if (products().length === 0) {
<p>No products</p>
} @else {
<ul>
@for (p of products(); track p.id) {
<li>{{ p.name }} <app-price [cents]="p.cents" /></li>
}
</ul>
}
@defer (on viewport) {
<p>Reviews loaded</p>
} @placeholder {
<p>Reviews arrive later</p>
}
`,
})
export class Products {
private readonly http = inject(HttpClient);
protected readonly products = toSignal(this.http.get<Product[]>('/api/products'), { initialValue: [] });
}
@Component({
selector: 'app-root',
imports: [Products],
template: `<main><h1>Shop</h1><app-products /></main>`,
})
export class Root {}
Five lines deserve a closer reading. input.required<number>() declares an input as a signal: the value arrives from the parent and reading it before it has been set is an error. The computed euros derives the text from the price in cents (working in cents avoids the rounding errors of decimals). @for requires track p.id: it tells Angular how to recognise a row from one loop to the next, and without it the code does not compile. Finally toSignal turns the observable of the HTTP call into a signal with an initial value, so the template does not need the async pipe. The @defer (on viewport) block postpones the heavy part but, as you will see, on the server only the placeholder appears.
The Angular compiler also checks the templates. I wrote a component that passes the price a string instead of a number ([cents]="'4990'") and compiled it with ngc -p tsconfig.err.json, with the strictTemplates option turned on in my tsconfig:
import { Component } from '@angular/core';
import { PriceTag } from '../src/app.js';
@Component({
selector: 'app-bad',
imports: [PriceTag],
template: `<app-price [cents]="'4990'" />`,
})
export class Bad {}
// ngc error:
// errs/bad-template.ts:7:26 - error TS2322: Type 'string' is not assignable to type 'number'.
//
// 7 template: `<app-price [cents]="'4990'" />`,
// ~~~~~
The error is the same TS2322 you would get in TypeScript code, but it points inside the template, at the column of the wrong expression. The cost is that this check exists only if you compile with the Angular compiler (ng build does it automatically); a test that runs the template without compiling it, for example by loading it as text, does not see the problem and the wrong value arrives at runtime.
The server delivers HTML, the browser picks it up
The listing that follows is the heart of the chapter. It builds the application with three providers: provideServerRendering() (from @angular/platform-server), which prepares server-side rendering, provideClientHydration() (from @angular/platform-browser), which enables hydration and state transfer, and provideHttpClient(withFetch()), which makes HTTP calls use fetch. No value has a default that does this for you: if you do not write the providers, it does not happen. The hydration documentation also warns that provideClientHydration() must be included in the providers used for the bootstrap on the server, not only in the browser's.
import '@angular/compiler';
import { enableProdMode } from '@angular/core';
import { bootstrapApplication, provideClientHydration } from '@angular/platform-browser';
import { provideServerRendering, renderApplication } from '@angular/platform-server';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { Root, type Product } from './app.js';
enableProdMode();
const products: Product[] = [
{ id: 1, name: 'Keyboard', cents: 4990 },
{ id: 2, name: 'Mouse', cents: 1950 },
];
// No network: replace fetch with a function that answers from memory
let fetchCalls = 0;
globalThis.fetch = async (input) => {
fetchCalls++;
console.log('fetch request:', String(input));
return new Response(JSON.stringify(products), {
status: 200,
headers: { 'content-type': 'application/json' },
});
};
const html = await renderApplication(
(context) =>
bootstrapApplication(
Root,
{
providers: [provideServerRendering(), provideClientHydration(), provideHttpClient(withFetch())],
},
context,
),
{
document: '<!doctype html><html><head><title>Shop</title></head><body><app-root></app-root></body></html>',
url: '/',
},
);
const start = html.indexOf('<body>') + '<body>'.length;
const end = html.indexOf('<script id="ng-state"');
console.log(html.slice(start, end).replaceAll('><', '>\n<'));
const stateJson = /<script id="ng-state"[^>]*>(.*?)<\/script>/s.exec(html)?.[1] ?? '{}';
const state = JSON.parse(stateJson) as Record<string, { u?: string; s?: number; b?: unknown }>;
for (const [key, entry] of Object.entries(state)) {
if (key === '__nghData__') continue;
console.log('HTTP response in state:', entry.u, entry.s, JSON.stringify(entry.b));
}
console.log('state keys:', Object.keys(state).map((k) => (k.length > 12 ? k.slice(0, 8) + '...' : k)));
console.log('fetch calls:', fetchCalls);
// Output:
// fetch request: http://localhost/api/products
// <!--nghm-->
// <app-root ng-version="22.2.6" ngh="0" ng-server-context="other">
// <main>
// <h1>Shop</h1>
// <app-products ngh="1">
// <h2>Products</h2>
// <!---->
// <ul>
// <li>Keyboard <app-price ngh="0">
// <span class="price">49.90 EUR</span>
// </app-price>
// </li>
// <li>Mouse <app-price ngh="0">
// <span class="price">19.50 EUR</span>
// </app-price>
// </li>
// <!---->
// </ul>
// <!---->
// <!---->
// <!---->
// <p>Reviews arrive later</p>
// <!---->
// </app-products>
// </main>
// </app-root>
// HTTP response in state: http://localhost/api/products 200 [{"id":1,"name":"Keyboard","cents":4990},{"id":2,"name":"Mouse","cents":1950}]
// state keys: [ 'c2e89662...', '__nghData__' ]
// fetch calls: 1
The HTML already contains the two products with the formatted price (49.90 EUR and 19.50 EUR): it is the content that a search engine or a browser without JavaScript reads. There was a single network call, to http://localhost/api/products (the request was relative, /api/products: rendering on the server makes it absolute, here with host localhost). The ng-state script holds the response with its URL and the 200 status: the SSR guide explains that HttpClient caches the requests made on the server and transfers them into the HTML, and that the browser reuses them instead of repeating them during the first rendering. The ngh attributes are the references that let the browser recognise the server's nodes.
What I did NOT verify: the behaviour in the browser. That the browser reuses the nodes and skips the second call I report from the documentation; I did not run any browser. What I measured is the server side: what it produces, and what changes if you remove hydration. The following listing renders the same application without provideClientHydration().
import '@angular/compiler';
import { enableProdMode } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideServerRendering, renderApplication } from '@angular/platform-server';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { Root } from './app.js';
enableProdMode();
globalThis.fetch = async () =>
new Response('[{"id":1,"name":"Keyboard","cents":4990}]', { headers: { 'content-type': 'application/json' } });
// Same application, but without provideClientHydration()
const html = await renderApplication(
(context) =>
bootstrapApplication(Root, { providers: [provideServerRendering(), provideHttpClient(withFetch())] }, context),
{ document: '<!doctype html><html><head></head><body><app-root></app-root></body></html>', url: '/' },
);
console.log('ngh attribute present:', html.includes('ngh='));
console.log('ng-state script present:', html.includes('id="ng-state"'));
console.log('product in HTML:', html.includes('Keyboard'));
// Output:
// ngh attribute present: false
// ng-state script present: false
// product in HTML: true
Without the provider the content is still there (the product is in the HTML) but the ngh attributes and the ng-state script disappear: the browser would receive a readable page and would have to redo the work from scratch, and the call to /api/products would be repeated. For pages where loading time matters, this difference is the reason why hydration is always enabled together with SSR.
What @defer renders on the server
In the output "Reviews arrive later" appears: the placeholder, not the content. This is the documented behaviour: with SSR or SSG the @defer blocks render the @placeholder (or nothing, if it is missing) and the triggers do not fire; in the browser the placeholder is hydrated and the triggers become active. To get the main content on the server as well, the guide points to the incremental hydration feature, which I did not try. The practical consequence is that what ends up inside a @defer is not seen by search engines that read only the initial HTML: you put in it what does not need to be found, such as the reviews below the fold.
Routing and functional guards
A guard decides whether a navigation can happen. Today it is a simple function that can call inject(), with no class and no interface to implement. The listing defines three pages and a guard that, if the user is not authenticated, returns a UrlTree to /login. The documentation on guards recommends exactly this: to redirect, return a UrlTree or a RedirectCommand instead of returning false and navigating by hand.
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
import { CanActivateFn, Router, RouterOutlet, type Routes } from '@angular/router';
@Component({ selector: 'app-home', changeDetection: ChangeDetectionStrategy.OnPush, template: `<h2>Home</h2>` })
export class Home {}
@Component({ selector: 'app-login', changeDetection: ChangeDetectionStrategy.OnPush, template: `<h2>Sign in</h2>` })
export class Login {}
@Component({ selector: 'app-area', changeDetection: ChangeDetectionStrategy.OnPush, template: `<h2>Private area</h2>` })
export class Area {}
// Functional guard: no class, no interface, just a function that uses inject()
const onlyAuthenticated: CanActivateFn = () => {
const loggedIn = false;
return loggedIn ? true : inject(Router).parseUrl('/login');
};
export const routes: Routes = [
{ path: '', component: Home },
{ path: 'login', component: Login },
{ path: 'private-area', component: Area, canActivate: [onlyAuthenticated] },
];
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: `<nav>Menu</nav><router-outlet />`,
})
export class RoutedRoot {}
The value loggedIn = false is fixed to make the test deterministic: in a real application you would read it from a service. The second file renders the same application for two URLs and prints the title of the page that appears.
import '@angular/compiler';
import { enableProdMode } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { provideServerRendering, renderApplication } from '@angular/platform-server';
import { provideRouter } from '@angular/router';
import { RoutedRoot, routes } from './routes.js';
enableProdMode();
for (const url of ['/', '/private-area']) {
const html = await renderApplication(
(context) =>
bootstrapApplication(
RoutedRoot,
{ providers: [provideServerRendering(), provideRouter(routes)] },
context,
),
{ document: '<!doctype html><html><head></head><body><app-root></app-root></body></html>', url },
);
const title = /<h2>(.*?)<\/h2>/.exec(html)?.[1];
console.log(url.padEnd(16), '->', title);
}
// Output:
// / -> Home
// /private-area -> Sign in
The URL / shows "Home"; /private-area, with the guard that refuses, shows "Sign in". The rendering follows the redirect and delivers the HTML of the destination page. The HTTP status code (200, 302, 404) is not in the HTML but in the response of the server that surrounds renderApplication: in this experiment there is no server and I did not measure it, so I do not say which status would be emitted.
Frequently asked questions
Does a signal replace RxJS?
No: they are different tools. The signal holds a current value and notifies whoever reads it; RxJS describes streams of events over time. In the listing toSignal connects them, turning an HTTP response into a signal with an initial value.
What happens if I use @defer in a page rendered on the server?
The server renders the placeholder, not the content, and the triggers do not fire; in the browser the placeholder is hydrated and the triggers become active. The official guide to deferred blocks says so, and it is what I observed in the test HTML.
Is provideClientHydration needed on the server too?
Yes: the hydration guide asks for it to be included in the providers used for the bootstrap on the server. In my test, without the provider both the ngh attributes and the transferred state disappear.
What you can do now
You can write a standalone component with a signal input, computed, @if and @for, and you can have the compiler check the template with strictTemplates. You can render an application on the server inside a test, read the ngh attributes and the transferred state in the HTML and show that the HTTP request goes out only once. You know that a @defer on the server leaves the placeholder and that a functional guard redirects with a UrlTree. To build the side that serves this data, move on to the chapter on Spring Boot and Clean Architecture; the full index is on the series page.
The series closes with Multi-environment CI/CD, which collects the list of all eight articles.
Sources
- Angular, Signals - lazy and memoized calculation of
computed, page downloaded and read on 2026-11-13 - Angular, Hydration - definition of hydration, requirement of
provideClientHydration()also in the server providers, page read on 2026-11-13 - Angular, Server-side and hybrid rendering - "Caching data when using HttpClient" section (cache on the server, transfer into the HTML, reuse in the browser), read on 2026-11-13
- Angular, Deferrable views - behaviour of
@deferwith SSR and SSG (placeholder only, triggers not invoked), read on 2026-11-13 - Angular, Route guards -
CanActivateFnand the advice to return aUrlTreeor aRedirectCommandto redirect, read on 2026-11-13 - npm registry, @angular/core package -
latestversion 22.2.6,v21-ltsline 21.2.26,enginesfield Node^24.15, queried withnpm viewon 2026-11-13
Commenti
Caricamento commenti...
Sign in to comment