Par défaut, Angular livre l’application en CSR (client-side rendering) : le serveur envoie un <app-root></app-root> vide, et le navigateur doit télécharger, analyser et exécuter tout le JavaScript — puis aussi récupérer les données — avant d’afficher quoi que ce soit d’utile. Le SSR (rendu sur le serveur, à chaque requête) et le SSG (prerender, au moment du build) déplacent le premier rendu ailleurs : l’utilisateur reçoit tout de suite du HTML avec du contenu.
Avec @angular/ssr, vous ne choisissez pas une fois pour toute l’application, mais par route : le fichier app.routes.server.ts indique pour chaque chemin s’il est en RenderMode.Prerender, RenderMode.Server ou RenderMode.Client. C’est ce qu’on appelle le hybrid rendering. L’API des routes serveur est apparue en v19 et est stable depuis la v20.
- Le rendu dans le navigateur (CSR), sur le serveur (SSR) ou au build (prerender) se choisit par route, avec
RenderModedansapp.routes.server.ts. - Prerender pour les pages identiques pour tous, SSR pour les pages personnalisées ou aux données qui changent souvent, CSR pour les zones derrière la connexion.
- Le SSR améliore le premier affichage et ce que voit un robot, mais ajoute un serveur à maintenir et du code qui doit aussi tourner sur le serveur.
- Les routes avec paramètres peuvent être prérendues si vous leur donnez la liste des valeurs, avec
getPrerenderParams. - Le cache du CDN compte souvent plus que la vitesse du serveur.
Le SSR n’est pas un interrupteur de performance gratuit. Vous gagnez en LCP, en SEO et en aperçus corrects sur les réseaux sociaux ; vous payez avec un serveur Node à exploiter, du code qui doit s’exécuter sans window, et une fenêtre pendant laquelle la page est visible mais ne répond pas encore aux clics. La bonne décision se prend par route, pas par projet.
Trois modes de rendu
CSR · Client | SSR · Server | SSG · Prerender | |
|---|---|---|---|
| Le HTML apparaît | dans le navigateur, après le JS | sur le serveur, à chaque requête | au moment du build, une fois |
| Le serveur, par requête | sert un fichier statique (le shell vide) | démarre l’application, demande les données, effectue le rendu | sert un fichier statique |
| TTFB | faible | inclut le rendu et les API qu’il appelle | minimal, surtout depuis un CDN |
| LCP | après JS + bootstrap + requête API | dès que le HTML arrive | dès que le HTML arrive |
| SEO, aperçus | faible : dépend du JS | excellent | excellent |
| Contenu par utilisateur | oui | oui | non — HTML identique pour tout le monde |
| Hébergement | n’importe quel hébergement statique / CDN | un runtime Node (ou un autre runtime JS) | n’importe quel hébergement statique / CDN |
| Coût par requête | ≈ 0 | CPU + mémoire pour un rendu complet | ≈ 0 |
Ce que fait concrètement le serveur en mode Server : pour chaque requête, @angular/ssr crée une nouvelle plateforme, appelle la fonction de bootstrap de main.server.ts, laisse le routeur naviguer vers l’URL, attend que l’application devienne stable (toutes les requêtes HTTP et les PendingTasks terminées), sérialise le DOM en HTML et détruit l’application. La conséquence pratique : le TTFB d’une page SSR est au moins aussi long que celui de l’API la plus lente appelée pendant le rendu.
Dans une vraie application hybride, les modes coexistent : la page d’accueil et les pages marketing sont prérendues, la recherche est rendue sur le serveur, et l’espace compte s’exécute uniquement dans le navigateur.
Ce qui se passe lors d’une requête
Cinq moments comptent, dans l’ordre où l’utilisateur les vit :
- TTFB (time to first byte) — le premier octet de la réponse HTML.
- FCP (first contentful paint) — le premier texte ou la première image à l’écran.
- LCP (largest contentful paint) — l’élément principal apparaît (le titre, l’image du produit). C’est la métrique que l’utilisateur ressent comme « c’est chargé ».
- Hydration — le JS est arrivé, Angular reprend le HTML existant et attache les listeners.
- Interactif — un clic fait vraiment quelque chose. À partir de ce moment, l’INP compte (la rapidité de réaction de la page).
Avec le SSR et le SSG, entre le LCP et l’état « interactif », il y a une fenêtre pendant laquelle la page semble prête, mais où les boutons n’ont pas encore de listener. Sur un téléphone modeste, avec un bundle de quelques centaines de KB, cette fenêtre dure facilement 1 à 3 secondes. Lancez la simulation et comparez les trois modes sur le même réseau.
SSR : le TTFB inclut le travail du serveur (qui attend l’API), puis le contenu apparaît immédiatement. Bande jaune = contenu visible mais sans listeners jusqu’à l’hydration.
Remarquez ce qui ne change pas : main.js a la même taille dans les trois modes. Le SSR ne réduit pas le JavaScript, donc l’interactivité arrive à peu près au même moment qu’en CSR — parfois même un peu plus tard, car le HTML est plus gros et l’hydration consomme aussi du CPU. Le vrai gain, c’est le LCP. Pour réduire la fenêtre non interactive : moins de JS initial (routes lazy, @defer), l’incremental hydration et l’event replay, qui ne perd pas les clics faits pendant cette fenêtre.
Ce que voit un crawler
Un crawler part du HTML qu’il reçoit (ce que vous voyez avec Afficher le code source, pas dans l’onglet Éléments). En CSR, ce HTML est identique pour n’importe quelle URL. Googlebot peut exécuter du JavaScript, mais plus tard et avec des limites ; les bots qui génèrent des aperçus dans les chats et les réseaux sociaux ne lisent généralement que le HTML initial. Il y a un détail souvent ignoré : le code de statut. Un serveur statique ne sait pas que /produse/9999 n’existe pas, il répond donc 200 — un « soft 404 ».
HTTP/1.1 200 OK<!doctype html><html lang="fr"><head> <title>Boutique</title> <link rel="stylesheet" href="styles-5INURTSO.css"></head><body> <app-root></app-root> <script src="main-HG7KQWZB.js" type="module"></script></body></html>
- ✓Statut HTTP correct200
- ✕<title> propre à la pageBoutique
- ✕meta description / og:*absent
- ✕Texte visible dans le HTML0 mot
- ✕Liens <a href> à suivre0
Le même HTML pour chaque URL : tout le contenu n’apparaît qu’après l’exécution du JavaScript. Les bots d’aperçu ne l’exécutent pas ; Googlebot le fait, mais plus tard et avec des limites.
Le titre et les balises meta se définissent avec les services Title et Meta de @angular/platform-browser. Ils passent par DOCUMENT, donc ils fonctionnent aussi sur le serveur : tout ce que vous définissez pendant le rendu se retrouve dans le HTML envoyé. Pour les pages sans données, la propriété title de la route suffit (ou votre propre TitleStrategy, si vous voulez un format commun).
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>(); // issu de la route produse/:id (withComponentInputBinding)
private readonly title = inject(Title);
private readonly meta = inject(Meta);
protected readonly product = httpResource<Product>(() => `/api/products/${this.id()}`);
constructor() {
// S’exécute aussi sur le serveur : les balises se retrouvent dans le HTML envoyé aux crawlers.
effect(() => {
if (!this.product.hasValue()) return;
const p = this.product.value();
this.title.setTitle(`${p.name} — Boutique`);
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 });
});
}
}Mettez la logique dans un service SeoService.update({ title, description, image, canonical }) et appelez-le depuis les pages de détail. Pour <link rel="canonical">, vous avez besoin de DOCUMENT — détails sur la page Code compatible serveur.
Mise en place : ce que génère ng add
# nouveau projet, avec SSR + prerender configurés dès le départ
ng new magazin --ssr
# projet existant : ajoute @angular/ssr, express et les fichiers serveur
ng add @angular/ssr
# build de production → dist/magazin/browser + dist/magazin/server
ng build
# démarre le serveur Node (script ajouté par ng add dans package.json)
npm run serve:ssr:magazin # = node dist/magazin/server/server.mjsLa commande ajoute les dépendances (@angular/ssr, express), quatre fichiers et quelques options dans angular.json. Il vaut la peine de savoir ce que fait chacun, car vous allez les modifier :
server.ts— le serveur Express. Il sert les fichiers statiques dedist/…/browseret confie le reste des requêtes àAngularNodeAppEngine.handle(), qui renvoie uneResponse(ounullsi l’URL n’est pas une route Angular). L’exportreqHandlerest utilisé par la CLI :ng serveeffectue aussi le rendu sur le serveur.main.server.ts— la fonction de bootstrap que le serveur appelle à chaque rendu.app.config.server.ts—provideServerRendering(withRoutes(serverRoutes)), combiné à la config du navigateur viamergeApplicationConfig.app.routes.server.ts— le render mode par route. Par défaut, tout est enPrerender.app.config.tsreçoitprovideClientHydration(), pour que le navigateur réutilise le HTML au lieu de le reconstruire.
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(); // une instance pour tout le serveur
// 1. (facultatif) vos API : app.get('/api/{*splat}', (req, res) => { ... });
// 2. Fichiers statiques (JS/CSS/images ont un hash dans leur nom → cache d’un an)
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false }));
// 3. Tout le reste : Angular décide — HTML prérendu, SSR ou shell CSR
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}`);
});
}
// utilisé par ng serve / ng build (et par certaines plateformes serverless)
export const reqHandler = createNodeRequestHandler(app);Un détail dans server.ts : express.static a index: false, donc même / n’est pas servi directement depuis le disque. Chaque page passe par Angular, qui applique le render mode, les headers et le statut configurés pour la route.
Pour prévenir le SSRF, le serveur valide l’en-tête Host. Depuis la v22, un host inconnu reçoit 400 Bad Request (avant, le serveur basculait en CSR). ng add met "allowedHosts": [] : renseignez vos domaines dans angular.json (les jokers comme *.magazin.ro sont acceptés), dans les options de AngularNodeAppEngine ou dans la variable d’environnement NG_ALLOWED_HOSTS. Derrière un load balancer, les en-têtes X-Forwarded-* sont ignorés tant que vous ne les autorisez pas explicitement avec trustProxyHeaders. Détails dans Sécurité.
Si vous avez un projet plus ancien dont le server.ts utilise CommonEngine : cette API est dépréciée depuis la v22, son remplaçant étant AngularNodeAppEngine (Node) ou AngularAppEngine (runtimes basés sur les Request/Response de la Web API).
RenderMode par route
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
// Identique pour tous, ne change qu’au déploiement → HTML généré au build (SSG)
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'despre', renderMode: RenderMode.Prerender },
// Public, indexé, données qui changent souvent → rendu à chaque requête (SSR)
{ path: 'cautare', renderMode: RenderMode.Server },
// Derrière la connexion : pas de SEO, HTML personnel → navigateur uniquement (CSR)
{ path: 'cont/**', renderMode: RenderMode.Client },
// Le filet de sécurité pour toute route Angular non listée ci-dessus
{ path: '**', renderMode: RenderMode.Server },
];Les règles qui comptent en pratique :
- Correspondance : un segment exact l’emporte sur un paramètre (
:id), qui l’emporte sur**. Donc**est le filet de sécurité, pas une règle qui « écrase ». - Validation dans les deux sens : chaque route du routeur doit correspondre à une route serveur, et chaque route serveur (sauf celles qui se terminent par
/**) doit exister dans le routeur. Sinon, le build s’arrête avec un message explicite. - Le chemin ne commence pas par « / » —
'/despre'est une erreur de build. - Ce que reçoit le navigateur :
Prerender→ le fichier généré au build (avec unETag) ;Server→ le HTML rendu maintenant ;Client→index.csr.html, le shell vide, lui aussi servi par votre serveur.
- Le HTML est identique pour tout le monde et stable : vous le générez une fois, au moment du build.
- Servi comme un fichier statique (idéalement depuis un CDN) : TTFB minimal, coût par requête quasi nul.
- Les données changent chaque jour → il vous faut un build planifié (p. ex. chaque nuit en CI) qui régénère les pages.
- Des milliers de pages ajoutent des minutes au build ; mesurez le temps de build et la taille du déploiement.
import { PrerenderFallback, RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: 'produse/:id',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const api = inject(CatalogApi); // synchrone, avant le await
const items = await api.mostVisited(500);
return items.map((x) => ({ id: String(x.id) }));
},
fallback: PrerenderFallback.Server,
},
// ...
];Headers, statut et redirections
Chaque ServerRoute peut avoir des headers et un status. L’exception : les routes Prerender acceptent headers, mais pas status — le type ServerRoutePrerender l’exclut, car le fichier n’est généré qu’une seule fois. Les headers d’une page prérendue ne s’appliquent que lorsqu’elle est servie par le serveur Node, pas depuis un hébergement statique.
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
// HTML public : le CDN peut le garder 60 s et servir l’ancienne version pendant qu’il se rafraîchit
path: 'cautare',
renderMode: RenderMode.Server,
headers: { 'Cache-Control': 'public, s-maxage=60, stale-while-revalidate=300' },
},
{
// redirection permanente : { path: 'oferte', redirectTo: 'promotii' } dans app.routes.ts
path: 'oferte',
renderMode: RenderMode.Server,
status: 301, // sans status : 302
},
{
// prerender : headers oui, status NON (le type ServerRoutePrerender l’exclut)
path: 'despre',
renderMode: RenderMode.Prerender,
headers: { 'X-Frame-Options': 'DENY' },
},
{ path: '**', renderMode: RenderMode.Server },
];Un redirectTo du routeur devient une vraie redirection HTTP sur le serveur (302 par défaut ; vous pouvez définir status: 301, 303, 307 ou 308), renvoyée sans rendre l’application. De même, si un guard redirige pendant le rendu (voir Guards & resolvers), l’URL finale diffère de l’URL demandée et le serveur répond par une redirection. Avec outputMode: 'static', où il n’y a pas de serveur, les redirections sont prérendues sous forme de « soft redirects » avec un <meta http-equiv="refresh"> dans le HTML.
Statut et headers décidés à l’exécution
Quand le statut dépend des données (le produit n’existe pas), vous le définissez depuis le composant via RESPONSE_INIT. Pour les cookies, la langue ou le user agent, vous lisez la requête via REQUEST. Les deux viennent de @angular/core et valent null dans le navigateur, au prerender et au build — gérez toujours le cas null.
import { Component, RESPONSE_INIT, inject } from '@angular/core';
@Component({
selector: 'app-not-found',
template: `<h1>Page introuvable</h1>`,
})
export default class NotFound {
constructor() {
// RESPONSE_INIT vaut null dans le navigateur, au prerender et au build → toujours vérifier.
const response = inject(RESPONSE_INIT);
if (response) response.status = 404; // le crawler voit 404, et non « 200 avec un texte d’erreur »
}
}Prerender avec paramètres
Les routes sans paramètres sont découvertes et prérendues automatiquement. Pour produse/:id, Angular ne peut pas deviner quels IDs existent, vous lui fournissez donc une fonction getPrerenderParams. Elle s’exécute au moment du build, dans un contexte d’injection (vous pouvez utiliser inject()), et renvoie une liste d’objets paramètre → valeur. Une route Prerender avec des paramètres mais sans getPrerenderParams arrête le 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); // ✓ inject() synchrone, AVANT tout await
const ids = await api.topProductIds(500); // s’exécute au BUILD, pas à la requête
return ids.map((id) => ({ id: String(id) })); // les valeurs doivent être des chaînes
},
// /produse/9999 n’a pas été prérendu → que fait-on à la requête ?
fallback: PrerenderFallback.Server, // par défaut : SSR à la requête
},
{
path: 'blog/:slug/**', // paramètres + catch-all
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
return [{ slug: 'ssr', '**': 'partea/1' }]; // → /blog/ssr/partea/1
},
},
{ path: '**', renderMode: RenderMode.Server },
];Ce qui arrive à une URL qui n’a pas été générée au build dépend de fallback :
| PrerenderFallback | Pour /produse/9999 (non prérendu) | Quand le choisir |
|---|---|---|
Server (par défaut) | rendu à la demande, sur le serveur | un grand catalogue : prérendre le top N, le reste à la demande |
Client | reçoit le shell CSR, rendu dans le navigateur | vous voulez du SEO uniquement pour les pages générées et ne voulez pas de SSR |
None | Angular ne répond pas (la requête continue, en général un 404) | la liste issue du build est complète par définition |
inject() ne fonctionne que de façon synchrone, au début de la fonction. Un inject() écrit après un await n’est plus dans un contexte d’injection et lève une erreur. Récupérez toutes les dépendances dans les premières lignes, puis faites les requêtes.
Le coût est au moment du build : chaque URL est un rendu complet. Dix mille produits, c’est dix mille rendus à chaque déploiement et dix mille fichiers à téléverser. Si les données changent chaque jour, il faut aussi un build planifié qui les régénère.
Déploiement, cache et coûts
outputMode dans angular.json décide de ce que produit le build. Avec "server" (ce que définit ng add), vous obtenez dist/app/browser plus dist/app/server/server.mjs, et il vous faut un processus Node. Avec "static", vous n’obtenez que des fichiers : les pages prérendues et le shell CSR, déployables sur n’importe quel CDN. Une route RenderMode.Server en mode static est une erreur de build.
{
"projects": {
"magazin": {
"architect": {
"build": {
"options": {
"outputMode": "static"
}
}
}
}
}
}Les couches de cache
| Quoi | Comment | Attention |
|---|---|---|
| JS, CSS, images avec un hash | maxAge: '1y' dans express.static / CDN | les noms changent à chaque build, donc un cache long est sûr |
| HTML prérendu | fichier statique, ETag → 304 Not Modified | ne change qu’au déploiement |
| HTML SSR public | headers par route : public, s-maxage=60, stale-while-revalidate=300 | uniquement pour du contenu identique pour tout le monde |
| HTML SSR personnel | private, no-store | jamais public : le CDN le servirait à un autre utilisateur |
| Données HTTP pendant le rendu | transfer cache : les réponses GET/HEAD finissent dans le HTML et sont réutilisées à l’hydration | les requêtes avec Authorization/Cookie sont exclues par défaut |
Le transfer cache est la raison pour laquelle, dans la timeline, le SSR ne refait plus la requête API depuis le navigateur : les données récupérées par HttpClient sur le serveur sont sérialisées dans <script id="ng-state"> et lues au démarrage. Ses options (withHttpTransferCacheOptions, withNoHttpTransferCache) se trouvent sur la page Hydration. Aussi : sur le serveur, avec le backend fetch par défaut, chaque réponse HTTP est limitée à 1 MB (erreur NG02825) ; la limite se modifie avec provideServerRendering({ maxResponseBodySize }, …).
Coût opérationnel
- CPU par requête : chaque rendu SSR est un bootstrap complet. Sans cache CDN, le trafic des bots se paie en serveurs.
- TTFB lié au backend : le serveur attend la stabilité, donc une API lente ralentit toutes vos pages. Mettez en place des timeouts et déplacez les données secondaires dans @defer (sur le serveur, un bloc sans trigger
hydratene rend que le placeholder). - Mémoire partagée entre les requêtes : le code est évalué une fois par processus, donc les variables au niveau du module et les providers
useValuesont communs à tous les utilisateurs. - Un système de plus en production : logs, monitoring, scaling, version de Node, cold start sur serverless.
import { InjectionToken } from '@angular/core';
// ✗ État au niveau du module : sur le serveur, le module est évalué UNE SEULE fois,
// donc la variable est partagée par toutes les requêtes (et tous les utilisateurs).
let currentUser: string | null = null;
// ✗ useValue au niveau de l’application : la même valeur jusqu’au redémarrage du serveur.
export const RENDERED_AT = new InjectionToken<number>('RENDERED_AT');
const bad = { provide: RENDERED_AT, useValue: Date.now() };
// ✓ useFactory : s’exécute à chaque rendu (chaque requête a son propre injecteur).
const good = { provide: RENDERED_AT, useFactory: () => Date.now() };
// ✓ L’état par utilisateur vit dans des services (instanciés à chaque requête), pas dans des variables globales.Quand OUI, quand NON
- Pages publiques qui doivent être indexées et partagées : produits, articles, landing pages.
Prerendersi vous pouvez les générer au build,Serversinon. - Le LCP se traduit en argent (e-commerce, médias), et certains utilisateurs ont des téléphones et des réseaux modestes.
- Contenu stable (documentation, blog, marketing) :
Prerender+outputMode: 'static', sans serveur à maintenir. - Contenu public avec données fraîches et fort trafic :
Server+ un cache court au niveau du CDN. - Vous avez besoin de codes de statut corrects (404, 301) pour le SEO.
- Applications derrière une connexion (dashboard, admin, back-office) :
Client. Investissez dans un petit bundle, des routes lazy et @defer. - Vous n’avez personne pour exploiter un serveur Node : restez en CSR ou en
PrerenderavecoutputMode: 'static'. - « L’application est lente après le chargement » : le SSR n’aide pas après l’hydration. Le problème est dans le JS ou dans la change detection → Performance, Change detection.
- Routes construites sur des API du navigateur (cartes, canvas, éditeurs) :
RenderMode.Clientsur cette route. - HTML personnalisé avec fort trafic : ne faites pas de rendu par utilisateur. Prérendez la partie commune et chargez les éléments personnels dans le navigateur.
Pièges fréquents
Le code qui touche à window, document ou localStorage dans le constructeur, dans ngOnInit ou au niveau du module plante sur le serveur. La solution : afterNextRender, le token DOCUMENT et des implémentations de service distinctes pour le serveur et le navigateur — le tout sur la page Code compatible serveur.
Une variable let currentUser au niveau du module, ou un useValue calculé au démarrage, vit aussi longtemps que le processus Node, pas aussi longtemps que la requête. Mettez l’état dans des services (instanciés à chaque rendu) et utilisez useFactory pour les valeurs calculées par requête.
- Cache public sur du HTML personnalisé → un utilisateur voit la page d’un autre. Sur les routes
Serveravec des données personnelles, utilisezCache-Control: private, no-store. allowedHostsvide en production → 400 sur toutes les pages (v22). Configurez les domaines ouNG_ALLOWED_HOSTS.- « Soft 404 » : le composant « not found » répond 200. Définissez
status = 404viaRESPONSE_INIT. - Mauvais fallback SPA sur un hébergement statique : avec le prerender,
index.htmlest la page de la route/. Les routesClientdoivent retomber surindex.csr.html. - L’application ne devient pas stable : une
PendingTasksans nettoyage (ou, avec Zone.js, unsetIntervallancé dans la zone Angular) fait attendre le rendu, et la requête ne se termine jamais. - Un CDN qui minifie le HTML et supprime les commentaires → l’hydration échoue avec NG0507. Les commentaires d’Angular sont nécessaires ; désactivez cette option sur le CDN.
- « J’ai ajouté du SSR et le TTI n’a pas bougé » — c’est normal : le JS est le même. Mesurez le LCP, pas le TTI, et réduisez le JS initial séparément.
Aide-mémoire
// 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)); // 1 MB par défaut
// 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 au build (SSG)
{
path: 'produse/:id',
renderMode: RenderMode.Prerender,
getPrerenderParams: async () => [{ id: '1' }, { id: '2' }], // s’exécute au build
fallback: PrerenderFallback.Server, // | Client | None — Server par défaut
},
{ path: 'cautare', renderMode: RenderMode.Server, headers: { 'Cache-Control': 'no-store' } },
{ path: 'cont/**', renderMode: RenderMode.Client }, // shell vide, rendu dans le navigateur
{ path: '**', renderMode: RenderMode.Server }, // le filet de sécurité
];
// @angular/core — tous null dans le navigateur, au prerender et au build
inject(REQUEST); // Request : headers, cookies, URL
inject(RESPONSE_INIT); // ResponseInit : status / headers définis dynamiquement
inject(REQUEST_CONTEXT); // le deuxième argument de angularApp.handle(req, context)
// angular.json → architect.build.options
// "outputMode": "server" | "static" static = pas de Node : uniquement Prerender + Client
// "security": { "allowedHosts": ["exemplu.ro"] } host inconnu → 400 Bad Request (v22)