Un'applicazione Angular che vuole farsi leggere subito, anche da un motore di ricerca o da un telefono lento, ha bisogno di due cose: che il server le consegni HTML già scritto e che il browser, ricevutolo, non lo butti via per rifarlo da capo. Il primo compito è il server-side rendering (SSR), il secondo l'idratazione (hydration). A rendere il tutto leggibile oggi sono due ingredienti dell'Angular moderno: componenti standalone, che dichiarano da soli ciò che usano, e signals, valori che sanno chi li legge. Dopo il capitolo su TypeScript, qui costruisci un piccolo negozio e ne guardi l'HTML che esce dal server.
Tutto gira in un progetto di prova mio, con Angular 22.2.6 (versione latest sul registro npm il 13 novembre 2026), TypeScript 6.0.3 (l'intervallo che il compilatore Angular 22 dichiara di richiedere) e Node.js 24.11. Una nota di onestà: il pacchetto @angular/core 22.2.6 dichiara nel campo engines Node ^24.15, quindi la mia macchina è sotto il minimo e ciò che segue è una prova, non una conferma di compatibilità. Lo stesso codice, compilato con la 21.2.26 (la linea LTS al momento), dà la stessa struttura di HTML, con ng-version e un hash diversi. Non c'è nessun browser e nessun server in ascolto: il rendering lo fa la funzione renderApplication dentro Node, e la rete è sostituita da una funzione che risponde dalla memoria.
Signals: valori che sanno chi li legge
Un signal è un contenitore di valore che tiene traccia di chi lo legge. Un computed è un valore derivato da altri signal: si ricalcola solo quando serve e solo se qualcosa da cui dipende è cambiato. Il listato conta quante volte gira la funzione di derivazione, e quel contatore è l'unico modo onesto per vedere che cosa succede.
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('dopo la dichiarazione:', runs);
console.log('prima lettura:', total(), 'esecuzioni:', runs);
console.log('seconda lettura:', total(), 'esecuzioni:', runs);
qty.set(3);
console.log('dopo qty.set(3):', 'esecuzioni:', runs);
console.log('lettura:', total(), 'esecuzioni:', runs);
qty.set(3);
console.log('stesso valore:', total(), 'esecuzioni:', runs);
const label = computed(() => `${untracked(qty)} pezzi a ${price()}`);
console.log(label());
qty.set(9);
console.log(label());
price.set(110);
console.log(label());
// Output:
// dopo la dichiarazione: 0
// prima lettura: 200 esecuzioni: 1
// seconda lettura: 200 esecuzioni: 1
// dopo qty.set(3): esecuzioni: 1
// lettura: 300 esecuzioni: 2
// stesso valore: 300 esecuzioni: 2
// 3 pezzi a 100
// 3 pezzi a 100
// 9 pezzi a 110
Dopo la dichiarazione le esecuzioni sono zero: il computed è pigro, non calcola finché nessuno lo legge. La seconda lettura restituisce 200 senza rieseguire la funzione (il valore è in cache), e cambiare qty non provoca nulla finché nessuno rilegge: le esecuzioni restano a 1 e salgono a 2 alla lettura successiva. Impostare lo stesso valore (qty.set(3) con 3 già dentro) non invalida nulla. Questo comportamento, «lazily evaluated and memoized», è quello descritto nella guida ufficiale ai signals. L'ultima parte mostra untracked: la lettura di qty dentro il computed non crea una dipendenza, e infatti label() resta «3 pezzi a 100» dopo qty.set(9) e si aggiorna solo quando cambia price, mostrando 9 pezzi. È una trappola più che una funzione: usalo quando vuoi davvero ignorare un valore, non per «far smettere di aggiornarsi» un componente.
Un componente standalone con il nuovo controllo di flusso
Prima dei componenti standalone ogni componente doveva essere dichiarato in un NgModule, e per capire che cosa potesse usare un template bisognava leggere il modulo. Oggi il componente elenca nel decoratore ciò che usa (imports) e i moduli non servono. Il listato seguente definisce un componente figlio che formatta un prezzo, un componente che mostra la lista e il componente radice.
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>Prodotti</h2>
@if (products().length === 0) {
<p>Nessun prodotto</p>
} @else {
<ul>
@for (p of products(); track p.id) {
<li>{{ p.name }} <app-price [cents]="p.cents" /></li>
}
</ul>
}
@defer (on viewport) {
<p>Recensioni caricate</p>
} @placeholder {
<p>Le recensioni arrivano dopo</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>Negozio</h1><app-products /></main>`,
})
export class Root {}
Cinque righe meritano una lettura. input.required<number>() dichiara un input come signal: il valore arriva dal genitore e leggerlo prima che sia stato impostato è un errore. Il computed euros deriva il testo dal prezzo in centesimi (lavorare in centesimi evita gli errori di arrotondamento dei decimali). @for richiede track p.id: dice ad Angular come riconoscere una riga fra un ciclo e il successivo, e senza non compila. Infine toSignal trasforma l'osservabile della chiamata HTTP in un signal con un valore iniziale, così il template non ha bisogno del pipe async. Il blocco @defer (on viewport) rinvia la parte pesante, ma, come vedrai, sul server compare solo il segnaposto.
Il compilatore Angular controlla anche i template. Ho scritto un componente che passa al prezzo una stringa invece di un numero ([cents]="'4990'") e l'ho compilato con ngc -p tsconfig.err.json, con l'opzione strictTemplates accesa nel mio 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 {}
// Errore di ngc:
// errs/bad-template.ts:7:26 - error TS2322: Type 'string' is not assignable to type 'number'.
//
// 7 template: `<app-price [cents]="'4990'" />`,
// ~~~~~
L'errore è lo stesso TS2322 che avresti nel codice TypeScript, ma punta dentro il template, sulla colonna dell'espressione sbagliata. Il costo è che questo controllo esiste solo se compili con il compilatore Angular (ng build lo fa in automatico); un test che esegue il template senza compilarlo, per esempio caricandolo come testo, non vede il problema e il valore sbagliato arriva a runtime.
Il server consegna HTML, il browser lo riprende
Il listato che segue è il cuore del capitolo. Costruisce l'applicazione con tre provider: provideServerRendering() (da @angular/platform-server), che prepara il rendering lato server, provideClientHydration() (da @angular/platform-browser), che abilita l'idratazione e il trasferimento dello stato, e provideHttpClient(withFetch()), che fa usare fetch alle chiamate HTTP. Nessun valore ha un default che faccia questo per te: se non scrivi i provider, non succede. La documentazione sull'idratazione avverte anche che provideClientHydration() va incluso nei provider usati per l'avvio sul server, non solo in quelli del browser.
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: 'Tastiera', cents: 4990 },
{ id: 2, name: 'Mouse', cents: 1950 },
];
// Nessuna rete: sostituisco fetch con una funzione che risponde dalla memoria
let fetchCalls = 0;
globalThis.fetch = async (input) => {
fetchCalls++;
console.log('fetch richiesta:', 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>Negozio</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('risposta HTTP nello stato:', entry.u, entry.s, JSON.stringify(entry.b));
}
console.log('chiavi dello stato:', Object.keys(state).map((k) => (k.length > 12 ? k.slice(0, 8) + '...' : k)));
console.log('chiamate a fetch:', fetchCalls);
// Output:
// fetch richiesta: http://localhost/api/products
// <!--nghm-->
// <app-root ng-version="22.2.6" ngh="0" ng-server-context="other">
// <main>
// <h1>Negozio</h1>
// <app-products ngh="1">
// <h2>Prodotti</h2>
// <!---->
// <ul>
// <li>Tastiera <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>Le recensioni arrivano dopo</p>
// <!---->
// </app-products>
// </main>
// </app-root>
// risposta HTTP nello stato: http://localhost/api/products 200 [{"id":1,"name":"Tastiera","cents":4990},{"id":2,"name":"Mouse","cents":1950}]
// chiavi dello stato: [ 'c2e89662...', '__nghData__' ]
// chiamate a fetch: 1
Nell'HTML ci sono già i due prodotti con il prezzo formattato (49.90 EUR e 19.50 EUR): è il contenuto che un motore di ricerca o un browser senza JavaScript leggono. La chiamata di rete è stata una sola, a http://localhost/api/products (la richiesta era relativa, /api/products: il rendering sul server la rende assoluta, qui con host localhost). Nello script ng-state c'è la risposta con il suo URL e lo stato 200: la guida SSR spiega che HttpClient memorizza le richieste fatte sul server e le trasferisce nell'HTML, e che il browser le riusa invece di ripeterle durante il primo rendering. Gli attributi ngh sono i riferimenti che permettono al browser di riconoscere i nodi del server.
Cosa NON ho verificato: il comportamento nel browser. Che il browser riusi i nodi e salti la seconda chiamata lo riporto dalla documentazione; non ho eseguito nessun browser. Quello che ho misurato è il lato server: che cosa produce, e che cosa cambia se togli l'idratazione. Il listato seguente rende la stessa applicazione senza 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":"Tastiera","cents":4990}]', { headers: { 'content-type': 'application/json' } });
// Stessa applicazione, ma senza 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('attributo ngh presente:', html.includes('ngh='));
console.log('script ng-state presente:', html.includes('id="ng-state"'));
console.log('prodotto nel HTML:', html.includes('Tastiera'));
// Output:
// attributo ngh presente: false
// script ng-state presente: false
// prodotto nel HTML: true
Senza il provider il contenuto c'è ancora (il prodotto è nell'HTML) ma gli attributi ngh e lo script ng-state spariscono: il browser riceverebbe pagina leggibile e dovrebbe rifare da zero il lavoro, e la chiamata a /api/products sarebbe ripetuta. Per le pagine in cui conta il tempo di caricamento, questa differenza è il motivo per cui l'idratazione si abilita sempre insieme all'SSR.
Che cosa rende @defer sul server
Nell'output compare «Le recensioni arrivano dopo»: il segnaposto, non il contenuto. È il comportamento documentato: con SSR o SSG i blocchi @defer rendono il @placeholder (o niente, se manca) e i trigger non partono; nel browser il segnaposto viene idratato e i trigger si attivano. Per ottenere sul server anche il contenuto principale la guida indica la funzione di incremental hydration, che non ho provato. La conseguenza pratica è che ciò che finisce dentro un @defer non è visto dai motori di ricerca che leggono solo l'HTML iniziale: ci metti ciò che non serve per essere trovati, come le recensioni sotto la piega.
Routing e guardie funzionali
Una guardia decide se una navigazione può avvenire. Oggi è una semplice funzione che può chiamare inject(), senza classe e senza interfaccia da implementare. Il listato definisce tre pagine e una guardia che, se l'utente non è autenticato, restituisce un UrlTree verso /accesso. La documentazione sulle guardie consiglia proprio questo: per reindirizzare restituisci un UrlTree o un RedirectCommand invece di restituire false e navigare a mano.
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>Accedi</h2>` })
export class Login {}
@Component({ selector: 'app-area', changeDetection: ChangeDetectionStrategy.OnPush, template: `<h2>Area riservata</h2>` })
export class Area {}
// Guard funzionale: niente classe, niente interfaccia, solo una funzione che usa inject()
const soloAutenticati: CanActivateFn = () => {
const loggedIn = false;
return loggedIn ? true : inject(Router).parseUrl('/accesso');
};
export const routes: Routes = [
{ path: '', component: Home },
{ path: 'accesso', component: Login },
{ path: 'area-riservata', component: Area, canActivate: [soloAutenticati] },
];
@Component({
selector: 'app-root',
imports: [RouterOutlet],
template: `<nav>Menu</nav><router-outlet />`,
})
export class RoutedRoot {}
Il valore loggedIn = false è fisso per rendere il test deterministico: in un'applicazione vera lo leggeresti da un servizio. Il secondo file rende la stessa applicazione per due URL e stampa il titolo della pagina che compare.
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 ['/', '/area-riservata']) {
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 titolo = /<h2>(.*?)<\/h2>/.exec(html)?.[1];
console.log(url.padEnd(16), '->', titolo);
}
// Output:
// / -> Home
// /area-riservata -> Accedi
L'URL / mostra «Home»; /area-riservata, con la guardia che rifiuta, mostra «Accedi». Il rendering segue il reindirizzamento e consegna l'HTML della pagina di destinazione. Il codice di stato HTTP (200, 302, 404) non è nell'HTML ma nella risposta del server che sta attorno a renderApplication: in questo esperimento non c'è un server e non l'ho misurato, quindi non dico quale stato verrebbe emesso.
Domande frequenti
Un signal sostituisce RxJS?
No: sono strumenti diversi. Il signal tiene un valore corrente e notifica chi lo legge; RxJS descrive flussi di eventi nel tempo. Nel listato toSignal li collega, trasformando una risposta HTTP in un signal con un valore iniziale.
Che cosa succede se uso @defer in una pagina renderizzata sul server?
Il server rende il segnaposto, non il contenuto, e i trigger non partono; nel browser il segnaposto viene idratato e i trigger si attivano. Lo dice la guida ufficiale ai blocchi differiti, ed è quello che ho osservato nell'HTML di prova.
Serve provideClientHydration anche sul server?
Sì: la guida sull'idratazione chiede di includerlo nei provider usati per l'avvio sul server. Nel mio test, senza il provider spariscono sia gli attributi ngh sia lo stato trasferito.
Che cosa sai fare adesso
Sai scrivere un componente standalone con input a signal, computed, @if e @for, e sai far controllare il template al compilatore con strictTemplates. Sai rendere un'applicazione sul server dentro un test, leggere nell'HTML gli attributi ngh e lo stato trasferito e dimostrare che la richiesta HTTP parte una volta sola. Sai che un @defer sul server lascia il segnaposto e che una guardia funzionale reindirizza con un UrlTree. Per costruire il lato che serve questi dati passa al capitolo su Spring Boot e la Clean Architecture; l'indice completo è nella pagina della serie.
La serie si chiude con CI/CD multi-ambiente, che ne raccoglie l'elenco degli otto articoli.
Fonti
- Angular, Signals - calcolo pigro e memorizzato dei
computed, pagina scaricata e letta il 2026-11-13 - Angular, Hydration - definizione di idratazione, obbligo di
provideClientHydration()anche nei provider del server, pagina letta il 2026-11-13 - Angular, Server-side and hybrid rendering - sezione «Caching data when using HttpClient» (cache sul server, trasferimento nell'HTML, riuso nel browser), letta il 2026-11-13
- Angular, Deferrable views - comportamento di
@defercon SSR e SSG (solo segnaposto, trigger non invocati), letta il 2026-11-13 - Angular, Route guards -
CanActivateFne consiglio di restituireUrlTreeoRedirectCommandper reindirizzare, letta il 2026-11-13 - Registro npm, pacchetto @angular/core - versione
latest22.2.6, lineav21-lts21.2.26, campoenginesNode^24.15, interrogato connpm viewil 2026-11-13
Commenti
Caricamento commenti...
Accedi per commentare