~10 min de lecture

NG0500 : les 3 vraies causes du mismatch d'hydratation Angular (et les 2 qu'on accuse à tort)

TL;DR

NG0500 signifie qu'au moment d'hydrater, Angular n'a pas retrouvé dans le DOM la structure que ton application client s'apprête à produire. En dev, l'erreur est verbeuse et le démarrage de l'app meurt. En build de production, il n'y a aucune erreur : les vérifications sont derrière ngDevMode, le drapeau que le build de prod supprime ; Angular s'attache au mauvais nœud en silence. Les causes réelles : du HTML que le navigateur réécrit au parsing, une mutation DOM qui insère ou supprime sur le chemin d'Angular, et du HTML altéré en transit. Deux accusés à tort, mesures à l'appui : le @if qui diverge entre serveur et client, et l'interpolation non déterministe. L'hydratation est stable depuis Angular 17 (developer preview en 16) ; les correctifs reposent sur afterNextRender (16.2+). Exemples en syntaxe Angular 22.

Le symptôme (et pourquoi tu ne le vois qu'en dev)

Tu as activé le SSR, provideClientHydration() est en place, Lighthouse est content. Tu lances en dev, et la console affiche :

ERROR RuntimeError: NG0500: During hydration Angular expected <tr> but found <tbody>.

Angular expected this DOM:
[...]
Actual DOM is:
[...]
To fix this problem:
  * check the "PriceTable" component for hydration-related issues
[...]

Le message complet fait une vingtaine de lignes : DOM attendu, DOM réel, composant fautif, lien vers la doc. Et derrière l'erreur, ton app est morte : le bootstrap a échoué, la page reste figée dans l'état où l'échec l'a laissée (le HTML serveur, éventuellement déjà écorné par les premières écritures du rendu client), sans aucun listener. Contrairement à ce qu'on lit souvent, Angular ne "jette pas le DOM serveur pour re-rendre côté client" : ce comportement destructif, c'est celui du SSR sans hydratation, ou de ngSkipHydration. Un mismatch, lui, ne re-rend rien du tout.

En production, c'est plus sournois : pas d'erreur du tout, les vérifications gardées par ngDevMode sont supprimées du bundle prod. Angular s'attache au mauvais nœud sans rien dire : un attribut posé sur le mauvais élément, un listener au mauvais endroit, ou un TypeError opaque quand la traversée du DOM par Angular tombe sur un trou. Un bug d'affichage qui n'existe qu'en prod SSR doit te faire soupçonner un mismatch. D'où la règle numéro un : reproduis en dev, le seul endroit où le framework te parle.

Ce qu'Angular compare vraiment

Au premier rendu client, au lieu de créer les nœuds, Angular avance dans le DOM existant en parallèle de ses instructions de rendu : "ici je dois produire un <div>, est-ce que le nœud courant est un <div> ?". Il compare la structure : type de nœud, balise, position. Ni le texte, ni les attributs. Il se repère grâce aux annotations posées par le serveur : l'attribut ngh sur les composants, le JSON d'hydratation dans le <script id="ng-state">, un commentaire marqueur <!--nghm-->, et des ancres de commentaire pour les conteneurs des blocs @if/@for.

Deux conséquences directes :

  1. Tout ce qui modifie la structure entre la sérialisation serveur et le premier rendu client déclenche une erreur de la famille : NG0500 (mauvais nœud, ou nœud absent là où Angular avançait), NG0501 (nœuds frères manquants), NG0502 (élément absent à cet endroit).
  2. Ce qui ne modifie que le contenu textuel ou les attributs d'un nœud existant ne déclenche rien. On y revient : c'est là que vivent les deux fausses causes.

Cause 1 : le HTML que le navigateur réécrit à ta place

Le grand classique, dont la version la plus sournoise part d'un markup parfaitement valide.

<!-- price-table.html -->
<table class="prices">
  <tr>
    <td>Module 1</td>
    <td>49 EUR</td>
  </tr>
</table>

Le standard n'exige pas de <tbody> dans une table, mais les navigateurs modernes en créent un au parsing. Le serveur sérialise table > tr ; le navigateur construit table > tbody > tr ; Angular attend un <tr> et trouve un <tbody> : c'est le message du bloc plus haut. Même famille, mais invalide celui-là : un <a> dans un <a>, que le parseur répare en fermant le premier avant d'ouvrir le second.

Le correctif : écris ce que le navigateur va construire.

<!-- price-table.html -->
<table class="prices">
  <tbody>
    <tr>
      <td>Module 1</td>
      <td>49 EUR</td>
    </tr>
  </tbody>
</table>

Et le fameux <div> dans un <p> des blogs React ? Écrit littéralement dans le template, il ne peut pas t'arriver : le compilateur applique lui-même les fermetures implicites (et lève NG5002 si tu as écrit le </p> fermant). Mais il redevient possible dès que le <div> arrive indirectement : via un bloc @if, ou via un composant enfant à racine <div> posé dans le <p> (<p><app-note /></p>, pattern banal). Le compilateur ne voit que les enfants directs du <p> ; le navigateur voit le DOM final, casse le paragraphe, et tu prends NG0500 ou NG0502.

Cause 2 : la mutation DOM qui insère ou supprime sur le chemin d'Angular

Deuxième cause : ton code (ou une lib tierce) modifie le DOM pendant la phase où Angular s'attend à le retrouver intact.

// promo-banner.ts
import { Component, ChangeDetectionStrategy, ElementRef, OnInit, inject } from '@angular/core';

@Component({
  selector: 'app-promo-banner',
  template: `
    <div class="banner">
      <span class="badge">-50%</span>
      <span class="label">Summer sale</span>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PromoBanner implements OnInit {
  private readonly host = inject(ElementRef);

  ngOnInit(): void {
    // Runs on the server too: the serialized HTML no longer
    // contains the badge the template describes.
    this.host.nativeElement.querySelector('.badge')?.remove();
  }
}

ngOnInit s'exécute aussi côté serveur : le HTML sérialisé ne contient plus le <span class="badge"> que le template décrit. À l'hydratation, Angular cherche ce <span> et tombe sur un trou : NG0500: During hydration Angular expected <span> but the node was not found.

Le piège dans le piège : la mutation ne casse que si elle insère avant ou supprime un nœud sur le chemin de traversée. Un appendChild en fin de conteneur ne jette rien ; il te laisse un nœud dupliqué en silence (le serveur a sérialisé le sien, le client ajoute le deuxième). L'absence d'erreur ne veut pas dire absence de problème.

Le correctif : repousser toute mutation DOM après l'hydratation avec afterNextRender (Angular 16.2+), qui ne s'exécute que dans le navigateur, une fois le rendu terminé.

// promo-banner.ts
import { Component, ChangeDetectionStrategy, ElementRef, afterNextRender, inject } from '@angular/core';

@Component({
  selector: 'app-promo-banner',
  template: `
    <div class="banner">
      <span class="badge">-50%</span>
      <span class="label">Summer sale</span>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PromoBanner {
  private readonly host = inject(ElementRef);

  constructor() {
    afterNextRender(() => {
      this.host.nativeElement.querySelector('.badge')?.remove();
    });
  }
}

Le serveur sérialise le template tel quel, le client hydrate un DOM conforme, et la mutation arrive après, quand plus personne ne compare. Une lib que tu ne contrôles pas (jQuery embarqué, widget de consentement) ? Même recette : initialise-la dans afterNextRender.

Cause 3 : le HTML altéré entre le serveur et le navigateur

La plus difficile à diagnostiquer, parce qu'elle ne vient pas de ton code :

  • Un CDN ou un proxy "optimiseur" qui minifie le HTML et supprime les commentaires. Angular vérifie l'intégrité du HTML SSR avant même d'hydrater, sur la présence du marqueur <!--nghm--> : commentaires supprimés, tu prends NG0507: Angular hydration logic detected that HTML content of this page was modified [...]. Particularité : c'est la seule erreur d'hydratation qui survive en build de production. Désactive la minification HTML sur les réponses SSR.
  • Les extensions de navigateur (traducteurs, bloqueurs, assistants d'écriture) qui injectent des nœuds avant que ton bundle s'exécute. C'est le mismatch qui n'existe que sur le poste d'un seul utilisateur.
  • Les scripts tiers chargés dans le <head> qui injectent du markup dans ton arbre applicatif au lieu de cibler <body>.

Un cousin de config plutôt que de transit : preserveWhitespaces. La doc est claire : l'activer avec l'hydratation n'est pas encore pleinement pris en charge, et surtout une valeur différente entre le tsconfig serveur et le tsconfig client casse l'hydratation. Laisse le défaut, partout.

Le correctif est dans l'infra ou la config, pas dans le composant. Diagnostic : compare la réponse serveur brute (curl sur ta route SSR) au DOM de l'inspecteur ; l'écart te dit qui altère quoi.

Fausse cause 1 : le @if qui diverge entre serveur et client

Maintenant, les accusés à tort. Premier suspect habituel :

// user-menu.ts
import { Component, ChangeDetectionStrategy, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';

@Component({
  selector: 'app-user-menu',
  template: `
    @if (isBrowser) {
      <button type="button">My account</button>
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserMenu {
  protected readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
}

Serveur : branche vide. Client : un bouton. Divergence de structure, donc NG0500 ? Non. Mesuré sur Angular 22 : aucune erreur, dans les deux sens, même quand le bloc est entouré de nœuds frères. Un bloc @if sérialise une ancre de conteneur (un commentaire HTML qui marque l'endroit où les vues s'insèrent) avec la liste de ses vues ; côté client, créer la vue manquante est une insertion ordinaire, rien n'est comparé. Un @for dont la collection diverge, pareil : pas d'erreur.

À la place, tu prends un layout shift (le contenu qui saute sous les yeux de l'utilisateur), que la doc pointe pour ce pattern exact, et la pénalité Core Web Vitals qui va avec. Le correctif reste donc pertinent, pour la bonne raison cette fois :

// user-menu.ts
import { Component, ChangeDetectionStrategy, signal, afterNextRender } from '@angular/core';

@Component({
  selector: 'app-user-menu',
  template: `
    @if (ready()) {
      <button type="button">My account</button>
    } @else {
      <span class="menu-placeholder" aria-hidden="true"></span>
    }
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UserMenu {
  protected readonly ready = signal(false);

  constructor() {
    afterNextRender(() => this.ready.set(true));
  }
}

Même structure des deux côtés, bascule au cycle de détection suivant, et si tu donnes au placeholder les dimensions du bouton en CSS, zéro layout shift. Pour la suite : les pièges de window, document et PLATFORM_ID en SSR.

Fausse cause 2 : l'interpolation non déterministe

Second accusé : le Math.random() ou le timestamp rendu dans le template, qui "casserait l'hydratation". Teste l'affirmation :

// request-badge.ts
import { Component, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-request-badge',
  template: `<span class="badge">{{ requestId }}</span>`,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RequestBadge {
  protected readonly requestId = Math.random().toString(36).slice(2, 8);
}

Le serveur rend une valeur, le client en calcule une autre. Et pourtant : pas de NG0500. Angular compare la structure, jamais le contenu des nœuds texte ni les attributs (le message NG0500 le dit en toutes lettres : "attributes [...] have no effect on hydration mismatches"). Le <span> est un <span>, l'hydratation réussit, puis la première détection de changements applique la valeur client par-dessus. Résultat : pas une erreur, un flash de contenu.

Pas un feu vert pour autant : le flash reste un défaut visuel, et la bonne pratique est de stabiliser la donnée (la calculer côté serveur et la passer au client via TransferState). Mais sache ce que tu débogues : si tu vois NG0500, cherche une divergence de structure, pas de valeur.

ngSkipHydration : la sortie de secours, pas la solution

Quand le mismatch vient d'un composant que tu ne peux pas corriger, Angular fournit un levier d'exclusion :

<app-legacy-widget ngSkipHydration />

L'attribut doit être statique et posé sur l'élément hôte (ou déclaré via host: { ngSkipHydration: 'true' } dans le composant) ; un binding est ignoré en silence, sans avertissement. Il exclut le composant et tout son sous-arbre de l'hydratation : ce fragment est détruit et re-rendu côté client, comme au temps du SSR destructif. Traite-le comme un pansement localisé : sur un widget de chat en bas de page, c'est un compromis raisonnable ; sur ton composant racine, c'est ton hydratation que tu annules tout court. Ne le confonds pas avec l'hydratation incrémentale : elle retarde l'hydratation d'un sous-arbre en réutilisant le DOM serveur, pas en le jetant.

Récap actionnable

Face à un soupçon de mismatch :

  1. Reproduis en dev. C'est le seul endroit où NG0500 existe : en prod les vérifications sont supprimées du bundle et Angular corrompt le DOM en silence. Le message de dev nomme le composant fautif ; Angular DevTools (18+) affiche le statut d'hydratation par composant.
  2. Cherche le HTML que le navigateur réécrit. <tr> sans <tbody> (valide mais réécrit), <a> imbriqués (invalide et réparé), composant à racine <div> posé dans un <p> : le navigateur corrige, le mismatch suit.
  3. Traque les mutations DOM précoces. Insertion ou suppression sur le chemin d'Angular : NG0500 en dev, DOM corrompu en prod. Ajout en fin de conteneur : duplication silencieuse. Tout passe dans afterNextRender.
  4. Compare la réponse serveur (curl) au DOM de l'inspecteur. Un écart désigne un CDN, une extension ou un script tiers ; des commentaires supprimés donnent NG0507, même en prod. Et garde preserveWhitespaces au défaut des deux côtés.
  5. Ne perds pas de temps sur les @if de plateforme ni les interpolations. Les premiers font un layout shift, les secondes un flash : ni les uns ni les autres ne produisent NG0500. Corrige-les pour l'utilisateur, pas pour l'erreur.
  6. ngSkipHydration en dernier recours, sur le plus petit sous-arbre possible.

Le mismatch d'hydratation n'est pas une fatalité du SSR : c'est un contrat explicite entre ton template et ton HTML servi. Une fois que tu sais ce qu'Angular compare (la structure, l'ordre, les annotations) et ce qu'il ignore (le texte, les attributs), le diagnostic cesse d'être un "pourquoi ça casse qu'en prod" et devient dix minutes de méthode.

📧 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.