~10 min de lecture

RenderMode par route : ton app Angular n'a pas UN mode de rendu, elle en a trois

TL;DR

Depuis Angular v20, le mode de rendu se décide route par route dans app.routes.server.ts : RenderMode.Prerender (HTML généré au build), RenderMode.Server (rendu par requête) et RenderMode.Client (rien côté serveur). Trois choses avant de toucher au fichier : toute route non couverte par la config serveur fait échouer le build ; un segment :param en Prerender exige getPrerenderParams (le catch-all ** en est dispensé) ; et une route prérendue avec paramètres a un fallback SSR par défaut que tu n'as jamais demandé.

Ton app est passée en SSR il y a un an. Depuis, chaque requête sur ta page pricing, inchangée depuis trois deploys, réveille ton serveur Node pour re-rendre le même HTML. Et ton dashboard, propre à l'utilisateur connecté, se fait rendre côté serveur pour produire un squelette que le client re-remplit de toute façon.

Un seul mode pour toute l'app : deux façons de perdre

SSR partout, c'est payer un rendu par requête pour des pages qui ne changent qu'au deploy. Landing, CGV, articles de blog : leur HTML est identique pour tous les visiteurs entre deux mises en production. Les re-rendre à chaque requête, c'est du TTFB perdu (Time To First Byte, le délai avant le premier octet de la réponse) et un serveur qui encaisse ton pic de trafic alors qu'un CDN l'aurait absorbé sans te coûter un rendu.

Prerender partout, c'est l'inverse : tout ce qui dépend de la requête devient faux. Une recherche prérendue montre les résultats du moment du build, un dashboard prérendu montre le dashboard de personne. Et c'est le piège dans lequel on tombe le plus facilement, parce que c'est le défaut : en v22, ng add @angular/ssr génère un app.routes.server.ts qui envoie tout en Prerender :

import { RenderMode, ServerRoute } from '@angular/ssr';

export const serverRoutes: ServerRoute[] = [
  {
    path: '**',
    renderMode: RenderMode.Prerender
  }
];

Ce scaffold est un point de départ, pas une config. La bonne question n'est pas "SSR ou statique ?" mais "quel est le cycle de vie de cette route ?".

Le fichier qui tranche : app.routes.server.ts

Vocabulaire, une fois pour toutes : SSR (Server-Side Rendering) rend le HTML à chaque requête, SSG (Static Site Generation, le prerendering) le rend une fois au build, CSR (Client-Side Rendering) le rend dans le navigateur. L'API de ce fichier est stable depuis Angular v20 (developer preview en v19, sous d'autres noms de providers) : un tableau de ServerRoute, un mode par entrée.

import { inject } from '@angular/core';
import { RenderMode, ServerRoute } from '@angular/ssr';
import { ProductCatalog } from './catalog/product-catalog';

export const serverRoutes: ServerRoute[] = [
  {
    // Statique entre deux deploys : rendu une fois, au build
    path: 'pricing',
    renderMode: RenderMode.Prerender,
  },
  {
    // Une page par produit, liste connue au build
    path: 'product/:id',
    renderMode: RenderMode.Prerender,
    getPrerenderParams: async () => {
      const catalog = inject(ProductCatalog);
      const ids = await catalog.getAllIds();
      return ids.map((id) => ({ id }));
    },
  },
  {
    // Dépend de la requête : rendu à chaque requête
    path: 'search',
    renderMode: RenderMode.Server,
  },
  {
    // Dépend de l'utilisateur connecté : rien d'utile à rendre côté serveur
    path: 'dashboard',
    renderMode: RenderMode.Client,
  },
  {
    // Tout le reste
    path: '**',
    renderMode: RenderMode.Server,
  },
];

Et le câblage dans 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);

Deux propriétés à connaître :

L'ordre du tableau ne départage pas la spécificité. Contrairement au Router côté client, @angular/ssr matche par arbre de segments : l'exact gagne sur *, qui gagne sur **, où que soient les entrées. Mets le catch-all ** en premier si tu veux, product/:id matche quand même sa propre entrée. L'ordre ne décide que du recouvrement : deux entrées de même forme (product/:id et product/:ref se normalisent pareil) occupent le même nœud, et la dernière écrase la première, sans erreur ni warning.

Chaque route de l'app doit être couverte. Sans entrée catch-all **, toute route de app.routes.ts qui n'a pas sa propre entrée serveur fait échouer le build, avec une erreur par route oubliée :

The 'about' route does not match any route defined in the server
routing configuration. Please ensure this route is added to the
server routing configuration.

C'est pour ça que le catch-all ** est quasi systématique : il décide du mode par défaut de tout ce que tu n'as pas listé. Choisis-le consciemment, c'est la ligne la plus importante du fichier.

Le fallback que tu n'as pas choisi

Une route prérendue avec paramètres accepte une option fallback : que faire d'un chemin que getPrerenderParams n'a pas produit au build ? Trois réponses, et le défaut va te surprendre :

  • PrerenderFallback.Server : la page est rendue en SSR à la demande. C'est le défaut.
  • PrerenderFallback.Client : le navigateur reçoit le shell CSR (le index.csr.html émis par le build, un HTML sans contenu applicatif dont le <app-root> est vide) et se débrouille.
  • PrerenderFallback.None : la requête n'est pas prise en charge par le moteur - avec le server.ts du scaffold, ça finit en 404, celui d'Express et pas ta page 404.

Relis le premier point. Par défaut, ton site "statique" embarque un chemin SSR qui s'active dès qu'une URL n'a pas été prérendue. Test réel : retire un slug de la liste, relance le build, demande la page au serveur Node. Réponse : 200, HTML complet, rendu à la volée. Aucun warning, juste une page qui coûte un rendu par requête là où un fichier sur disque aurait suffi.

C'est un excellent défaut pour le contenu qui bouge : un article publié après le deploy est quand même servi, en SSR, jusqu'au build suivant. C'est aussi ce qui nuance le piège SSR des patterns de routing : une URL hors liste n'est pas un 404, c'est un rendu à la demande. Mais ce défaut a deux conditions cachées :

  1. Il te faut un runtime serveur en prod. Si tu déploies uniquement browser/ sur un hébergement statique, personne ne rend la page manquante : ton CDN décide (404, ou rewrite vers le shell CSR).
  2. Ton chemin SSR doit marcher, même pour ce que le build n'a jamais exercé. Un accès à window dans un composant que seules certaines pages utilisent (un embed vidéo, un widget d'avis) peut passer le build parce qu'aucune page prérendue ne le rend, puis casser la première requête qui passe par le fallback, trois semaines plus tard.

Si tu veux la garantie inverse, dis-le : fallback: PrerenderFallback.None, et une URL non prérendue n'est pas servie du tout, 404 au bout. Au moins, c'est toi qui as tranché.

headers et status par route

Chaque entrée accepte aussi des headers de réponse, et un code HTTP pour tous les modes sauf Prerender :

{
  path: 'search',
  renderMode: RenderMode.Server,
  headers: { 'Cache-Control': 'no-store' },
},
{
  path: 'gone/legacy-page',
  renderMode: RenderMode.Server,
  status: 410,
},

Le système de types tranche pour toi, et c'est instructif : status n'existe pas sur une route Prerender. Logique : une page prérendue est un fichier, et un fichier n'a pas de code de statut, c'est celui qui le sert qui en décide. Ta page 404 prérendue qui doit répondre 404, c'est donc une règle côté hébergeur (vercel.json, config nginx) - sauf à accepter l'arbitrage inverse, une route Server avec status: 404, au prix d'un rendu par requête.

Les headers, eux, sont acceptés partout, Prerender compris, mais ils vivent dans le manifeste du serveur (angular-app-manifest.mjs, le fichier que le build émet dans server/ et où la table des routes porte leurs headers et leur status - le navigateur ne le reçoit jamais) : un CDN qui sert browser/ directement les fait disparaître. Même logique pour fallback : le type ne l'accepte qu'avec getPrerenderParams, parce que sans paramètres, tous les chemins sont connus au build.

Piège 1 : un segment :param en Prerender sans getPrerenderParams arrête ton build

Prerender sur product/:id sans dire quels id existent, et le build s'arrête :

The 'product/:id' route uses prerendering and includes parameters,
but 'getPrerenderParams' is missing. Please define 'getPrerenderParams'
function for this route in your server routing configuration or
specify a different 'renderMode'.

La règle exacte : getPrerenderParams est exigé dès qu'un :param apparaît dans un chemin qui ne finit pas par /**. Le catch-all ** en est donc dispensé, c'est pour ça que le scaffold passe le build sans rien te demander.

La fonction tourne dans un contexte d'injection, au build : tu peux y faire un inject() de tes services, y compris un service qui interroge ton CMS. Elle marche aussi sur un catch-all, où le paramètre s'appelle '**' :

{
  path: 'docs/**',
  renderMode: RenderMode.Prerender,
  getPrerenderParams: async () => [
    { '**': 'getting-started/install' },
    { '**': 'guides/routing' },
  ],
},

Piège 2 : RenderMode.Client ne rend pas "moins", il ne rend rien

Passe une route en Client et regarde ton dossier de build : l'index.html de cette route disparaît. À la place, le serveur répond avec index.csr.html, un shell dont le <app-root></app-root> est vide. Pas de contenu, pas de meta de page, rien à indexer.

C'est exactement ce qu'il faut pour un dashboard derrière un login : tout y dépend d'un token que le serveur de rendu n'a pas, et rendre la page côté serveur, c'est produire un squelette que le client jette. Et c'est éliminatoire pour une page qui doit être crawlée. Si ton vrai besoin est "servir vite et hydrater moins", la réponse n'est pas Client, c'est Prerender plus l'hydratation incrémentale.

Piège 3 : getPrerenderParams fige la liste au moment du build

La fonction tourne au build, donc elle photographie ce que tes sources de données disent à cet instant. Deux conséquences :

  • Du contenu créé après le deploy n'a pas de page statique. Avec le fallback par défaut, il est servi en SSR sans que tu le voies ; avec None, il n'est pas servi. Dans les deux cas, la sortie propre est un rebuild.
  • Du contenu programmé (un article à date de publication future) fait dériver la liste d'URLs entre deux builds sans qu'aucun commit ne l'explique. Si ta CI compare des artefacts de build (sitemap, liste de routes), la cause du diff fantôme est dans l'horloge, pas dans la PR.

Règle simple : l'appel réseau est le cas normal, c'est lui qui donne la liste. Ce qui doit t'alerter, c'est la variation non tracée par un commit (date courante, feature flag distant) : tu l'assumes, ou tu la sors de la fonction.

Bonus : le 400 (ou le 200 vide) qui n'a rien à voir avec tes routes

Tu testes ton build en local, PORT=4321 node dist/mon-app/server/server.mjs, tu ouvres n'importe quelle page, et tu prends un 400 Bad Request :

Header "host" with value "localhost:4321" is not allowed.

Ce n'est pas ta config de routes. @angular/ssr valide le header Host de toute requête qui atteint le moteur Angular, une protection contre le SSRF (Server-Side Request Forgery : détourner ton serveur vers un host qu'il n'aurait pas dû viser), livrée en correctif de sécurité jusque sur les anciennes majeures (19.2.21, 20.3.17, 21.1.5). Ce que la v22 change : l'échec répond 400, là où les versions précédentes, tant que allowedHosts n'était pas configuré, se repliaient en silence sur le shell CSR, un 200 vide autrement plus vicieux à diagnostiquer. La validation couvre aussi tes pages prérendues dès que le moteur les sert ; seuls les fichiers du middleware statique en amont (assets, et toute page prérendue demandée par son chemin de fichier littéral, /index.html ou /about/index.html) passent au travers. Autorise ton host de test via security.allowedHosts dans la config de build, ou l'option allowedHosts de AngularNodeAppEngine.

Before / after

Avant : tout dans le même mode. Après : une config qui suit le cycle de vie de chaque page.

Route Cycle de vie RenderMode
pricing, cgv, landing Change au deploy Prerender
blog/:slug Change au deploy + contenu programmé Prerender + fallback assumé
search Change à chaque requête Server
dashboard Dépend de l'utilisateur connecté Client
** Le défaut que tu choisis Server ou Prerender, consciemment

Tes pages statiques partent du disque ou du CDN, ton serveur ne rend que ce qui dépend de la requête, ton dashboard ne gaspille plus un rendu que le client écrase. Une app, un build, un deploy.

Récap actionnable

  • Le scaffold '**' -> Prerender de ng add @angular/ssr est un point de départ : passe chaque route au crible. À quelle fréquence son HTML change-t-il, et pour qui ?
  • L'ordre de serverRoutes ne départage pas la spécificité, mais deux entrées de même forme se recouvrent en silence (la dernière gagne) ; et toute route non couverte est une erreur de build.
  • Un segment :param en Prerender = getPrerenderParams obligatoire (catch-all ** dispensé) ; la fonction supporte inject() et les paramètres '**'.
  • Le fallback par défaut d'une route prérendue avec paramètres est PrerenderFallback.Server : ton site "statique" a un chemin SSR dormant. Garde-le si tu déploies le serveur ; sinon, PrerenderFallback.None explicite.
  • RenderMode.Client sert un shell vide : parfait derrière un login, éliminatoire pour une page à indexer.
  • getPrerenderParams photographie tes données au build : contenu créé ou programmé après coup = pas de page statique avant le prochain rebuild.
  • Un 400 Bad Request (v22, ou v21 avec allowedHosts déjà configuré) ou un shell vide en 200 (v21 et avant, allowedHosts vide), c'est la validation du header Host, pas tes routes.

Le rendu hybride ne demande ni nouvelle infra ni nouveau framework : réponds route par route à une question que tu connais déjà. Qui a besoin de ce HTML, et quand change-t-il ? Ensuite, combine avec la chasse au double fetch et l'hydratation incrémentale : les trois attaquent le budget que ton utilisateur paie avant de pouvoir cliquer.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.