Módulo 24: SSR e hidratación

Objetivos del módulo

  • Entender qué problema resuelve generar el HTML antes de que llegue al navegador
  • Distinguir los tres modos de renderizado: en el cliente, en el servidor y prerenderizado
  • Añadir SSR a un proyecto con ng add @angular/ssr y saber para qué sirve cada fichero generado
  • Elegir el modo de cada ruta con ServerRoute, RenderMode y getPrerenderParams
  • Fijar códigos de estado, cabeceras y redirecciones desde la configuración de rutas
  • Configurar los hosts permitidos, sin los que el servidor responde 400 a todo
  • Comprender qué hace la hidratación y cómo reutiliza el DOM
  • Usar la hidratación incremental con los disparadores hydrate, y saber qué cambia entre Angular 22 y la LTS
  • Conservar los clics que ocurren antes de hidratar con event replay
  • Evitar peticiones duplicadas con la caché de transferencia, TransferState y la opción id de resource
  • Leer la petición y modificar la respuesta con REQUEST y RESPONSE_INIT
  • Escribir componentes que funcionen en un servidor: sin window, con afterNextRender, con PendingTasks y sin estado compartido entre usuarios
  • Diagnosticar y corregir los errores de hidratación

1. El problema: una página vacía

Todo lo que has construido hasta ahora funciona igual: el servidor entrega un index.html casi vacío y es el navegador quien, al ejecutar JavaScript, crea la interfaz. A esto se le llama renderizado en el cliente (client-side rendering, CSR). Es lo que hace una ruta de un proyecto sin SSR, y es también lo que devuelve la ruta /panel de nuestra aplicación de ejemplo. Este es el contenido real de la respuesta, sin hojas de estilo ni scripts:

<body>
  <app-root></app-root>
</body>

La página funciona, pero ese HTML tiene tres inconvenientes:

  • Tarda en verse. Hasta que no se descarga y se ejecuta el JavaScript, y después se piden los datos a la API, el usuario mira una pantalla en blanco. En un móvil modesto con mala cobertura son varios segundos.
  • Los robots ven poco. Un buscador moderno sabe ejecutar JavaScript, pero muchos otros robots no lo hacen: los que generan la vista previa de un enlace en una red social, por ejemplo, leen solo el HTML.
  • Depende por completo del JavaScript. Si falla un script, no hay nada.

La figura siguiente es una captura real de dos rutas de la misma aplicación con el JavaScript desactivado en el navegador. La de arriba se generó en el servidor; la de abajo, solo en el cliente.

Captura de la ruta de empleados con JavaScript desactivado: se ve la cabecera y la tabla con cinco empleados

Captura de la ruta del panel con JavaScript desactivado: la página está completamente en blanco

Figura 1. Las rutas /empleados (con SSR) y /panel (solo cliente) con el JavaScript desactivado. La primera tiene todo su contenido; la segunda es una página en blanco.

📘 Concepto: El renderizado en el servidor (server-side rendering, SSR) ejecuta tus componentes en un proceso de Node, genera el HTML con los datos ya dentro y se lo envía al navegador. El prerenderizado (static site generation, SSG) hace lo mismo una sola vez, al compilar, y guarda el resultado en ficheros. En ambos casos, después el navegador descarga el JavaScript y conecta la aplicación a ese HTML: eso es la hidratación (sección 5).

Cuando pasa a tratarse de sitios públicos (tiendas, noticias, documentación, páginas de producto) el SSR suele merecer la pena. En una herramienta interna detrás de un inicio de sesión, normalmente no aporta nada: los robots no entran y el usuario ya tiene el código en caché.


2. Los tres modos de renderizado

Angular permite elegir el modo ruta a ruta dentro de la misma aplicación. No tienes que decidir para toda la web.

Tres líneas de tiempo comparando Client, Server y Prerender: en Client el contenido llega al final; en Server y Prerender llega antes y la hidratación ocurre después

Figura 2. Quién construye el HTML y cuándo llega cada cosa al usuario.

Modo RenderMode Cuándo se genera el HTML Coste en el servidor Datos Úsalo para
Cliente RenderMode.Client En el navegador, siempre Ninguno Los más recientes Paneles privados, herramientas internas
Servidor RenderMode.Server En cada petición Alto: ejecuta Angular cada vez Recientes Páginas públicas que cambian: listados, stock
Prerender RenderMode.Prerender Una vez, en ng build Mínimo: sirve un fichero Los de la fecha de compilación Portadas, ayuda, fichas que cambian poco

Dos ideas ayudan a elegir:

  • ¿El HTML es igual para todos? Si depende del usuario (su nombre, su carrito), no se puede prerenderizar, y un Server tampoco se puede guardar en una caché pública sin cuidado.
  • ¿Cuánto tiempo puede estar desactualizado? Si una ficha de producto puede tardar un día en reflejar un cambio de descripción, prerenderízala y recompila cada noche. Si el stock debe ser exacto, usa Server, o bien prerenderiza la ficha y pide solo el stock desde el navegador.

3. Añadir SSR a un proyecto

Un proyecto nuevo puede nacer con SSR, y a uno existente se le añade con una orden:

ng new empresa --ssr     # proyecto nuevo
ng add @angular/ssr      # proyecto existente

La orden crea cuatro ficheros, modifica otros cuatro y añade dependencias. Esta es la salida real sobre un proyecto recién creado:

CREATE src/main.server.ts (300 bytes)
CREATE src/app/app.config.server.ts (438 bytes)
CREATE src/app/app.routes.server.ts (174 bytes)
CREATE src/server.ts (1677 bytes)
UPDATE angular.json (2228 bytes)
UPDATE tsconfig.app.json (432 bytes)
UPDATE package.json (994 bytes)
UPDATE src/app/app.config.ts (417 bytes)
Fichero Para qué sirve
src/server.ts Servidor Express: entrega los ficheros estáticos y pasa el resto de peticiones a Angular
src/main.server.ts Punto de entrada de Angular en el servidor (equivale a main.ts)
src/app/app.config.server.ts Proveedores solo de servidor, mezclados con los de app.config.ts
src/app/app.routes.server.ts Modo de renderizado de cada ruta
angular.json Añade server, outputMode y ssr.entry a las opciones de build
package.json Añade @angular/ssr, @angular/platform-server, express, @types/express y el script serve:ssr:<proyecto>
src/app/app.config.ts Añade provideClientHydration()

En app.config.ts solo aparece una línea nueva, que activa la hidratación en el navegador:

// src/app/app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideClientHydration } from '@angular/platform-browser';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideBrowserGlobalErrorListeners(),
    provideRouter(routes),
    provideClientHydration(),
  ],
};

Lo que genera ng add en la LTS 🔄 v22.0

🔄 Cambia en Angular 22.0. ng add @angular/ssr escribe provideClientHydration(). En la LTS (Angular 21) escribe provideClientHydration(withEventReplay()), porque allí event replay no viene activado. Si usas la LTS: conserva esa opción y añade withIncrementalHydration() si quieres usar los disparadores hydrate de la sección 6.

La configuración del servidor mezcla los proveedores comunes con los propios. Es el mismo fichero en las dos versiones:

// src/app/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);

server.ts: un Express corriente

El servidor generado es un programa de Express de unas 60 líneas. Sin los comentarios, su núcleo es este:

// src/server.ts
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();

// 1. Ficheros estáticos: JS, CSS, imágenes y las páginas prerenderizadas
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false }));

// 2. Todo lo demás lo renderiza Angular
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}`);
  });
}

export const reqHandler = createNodeRequestHandler(app);

Fíjate en tres cosas:

  • angularApp.handle(req) devuelve una Response estándar de la Web, o null si Angular no sabe qué hacer con esa petición. En ese caso, next() deja que Express siga y acabe con su 404 por defecto.
  • Al ser un Express normal, puedes añadir rutas propias (app.get('/salud', ...)) antes del paso 2.
  • reqHandler es lo que usa la CLI en ng serve y al compilar. Por eso sigue exportándose aunque arranques con node.

Compilar y arrancar

outputMode en angular.json decide qué produce ng build:

outputMode Resultado
"server" (lo que escribe ng add) Carpeta browser y carpeta server con un servidor de Node
"static" Solo ficheros estáticos. Todas las rutas deben ser Prerender o Client

Con "server" se arranca así, usando el script que añadió ng add:

ng build
npm run serve:ssr:empresa
Node Express server listening on http://localhost:4000

Durante el desarrollo no hace falta nada de esto: ng serve ya renderiza en el servidor, sin que tengas que lanzar Node por tu cuenta.

Hosts permitidos: el 400 de la primera ejecución 🔄 v22.0

🔄 Cambia en Angular 22.0. Sin allowedHosts, el servidor compilado responde 400 a todo. En la LTS (Angular 21) responde 200 con la aplicación vacía, de modo que se pierde el SSR sin ningún error visible. Si usas la LTS: declara también allowedHosts; solo con la lista hecha obtendrás el HTML generado en el servidor, y un host desconocido recibirá 400 igual que en 22.

Si compilas y abres http://localhost:4000 sin más, te llevarás una sorpresa. La aplicación responde 400 Bad Request a todas sus rutas, también a las prerenderizadas. Probamos con 127.0.0.1 y con localhost: el resultado fue el mismo. Esto es lo que aparece en la consola del servidor (nuestro laboratorio usaba el puerto 5324):

ERROR: Bad Request ("http://127.0.0.1:5324/").
Header "host" with value "127.0.0.1:5324" is not allowed.

For more information, see https://angular.dev/best-practices/security#preventing-server-side-request-forgery-ssrf

No es un fallo: es una protección. Cuando tu código hace http.get('/api/...') con una ruta relativa en el servidor, Angular construye la URL completa a partir de la cabecera Host de la petición. Si esa cabecera la controla un atacante, puede hacer que tu servidor pida datos a una máquina interna a la que él no llega. Esto se llama falsificación de peticiones en el servidor (server-side request forgery, SSRF). Por eso Angular solo acepta los nombres que tú declares, y hay tres maneras de hacerlo:

// angular.json, en projects.<proyecto>.architect.build.options
"security": {
  "allowedHosts": ["empresa.es", "*.empresa.es", "localhost"]
}
// src/server.ts
const angularApp = new AngularNodeAppEngine({ allowedHosts: ['empresa.es', 'localhost'] });
# Variable de entorno, sin recompilar. Lista separada por comas
NG_ALLOWED_HOSTS=empresa.es,localhost node dist/empresa/server/server.mjs

El nombre va sin puerto: con NG_ALLOWED_HOSTS=127.0.0.1, el servidor de pruebas de este módulo aceptó 127.0.0.1:5324. Un Host que no esté en la lista sigue recibiendo 400:

Host: evil.example.com  ->  400
Host: 127.0.0.1:5324    ->  200

La LTS merece atención aparte, porque su fallo no se ve. Sin lista, nuestro servidor en Angular 21 devolvió un 200 de 696 bytes para /empleados, sin rastro de la tabla. La página funciona en el navegador, porque se renderiza allí, y puedes tardar mucho en darte cuenta de que el SSR no está actuando. Compruébalo siempre con curl.

⚠️ Importante: No uses * para salir del paso. Permite cualquier host y deja sin efecto la protección, salvo que otra capa (un balanceador o un proxy inverso) ya valide esa cabecera.

Cabeceras de proxy 🔄 v22.0

🔄 Cambia en Angular 22.0. Las cabeceras Forwarded y X-Forwarded-* se ignoran por defecto. En la LTS (Angular 21), X-Forwarded-Host y X-Forwarded-Proto se aceptan salvo que digas lo contrario. Si usas la LTS: escribe new AngularNodeAppEngine({ trustProxyHeaders: false }) si no tienes un proxy de confianza delante, para tener la misma protección.

Un proxy inverso, como Nginx, recibe la petición del usuario y se la pasa a tu servidor Node. Para que este sepa cuál era el dominio original, el proxy añade cabeceras como X-Forwarded-Host. El problema es que cualquiera puede escribir esa cabecera a mano, así que solo es fiable si el proxy la sobrescribe siempre.

Si tu aplicación está detrás de un proxy de confianza, actívalas con trustProxyHeaders o con la variable NG_TRUST_PROXY_HEADERS:

// src/server.ts
const angularApp = new AngularNodeAppEngine({
  allowedHosts: ['empresa.es'],
  trustProxyHeaders: ['x-forwarded-host', 'x-forwarded-proto'],
});

true confía en todas. ng update a la versión 22 incluye una migración llamada trust-proxy-headers que añade esta opción a tu server.ts.


4. Modos de renderizado por ruta

Las rutas de siempre (app.routes.ts) dicen qué componente se muestra. Las rutas de servidor (app.routes.server.ts) dicen cómo se genera su HTML. Esta es la configuración de la aplicación EMPRESA de este módulo:

// src/app/app.routes.server.ts
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { PrerenderFallback, RenderMode, ServerRoute } from '@angular/ssr';
import { firstValueFrom } from 'rxjs';
import { API, Departamento, Pagina } from './modelos';

export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender },
  { path: 'departamentos', renderMode: RenderMode.Prerender },
  {
    path: 'departamentos/:id',
    renderMode: RenderMode.Prerender,
    fallback: PrerenderFallback.Server,
    async getPrerenderParams() {
      const http = inject(HttpClient);
      const pagina = await firstValueFrom(
        http.get<Pagina<Departamento>>(`${API}/departamentos?tamano=3`),
      );
      return pagina.contenido.map((d) => ({ id: String(d.deptId) }));
    },
  },
  {
    path: 'empleados',
    renderMode: RenderMode.Server,
    headers: { 'Cache-Control': 'public, max-age=60' },
  },
  { path: 'panel', renderMode: RenderMode.Client },
  { path: 'noticias-viejas', renderMode: RenderMode.Server, status: 410 },
  { path: '**', renderMode: RenderMode.Server },
];

Se evalúan por coincidencia con la URL y ** recoge el resto. Cada entrada debe corresponder a una ruta real de app.routes.ts. Si no, el build falla con un mensaje claro:

ERROR  The 'noticias-viejas' server route does not match any routes defined in the Angular routing configuration (typically provided as a part of the 'provideRouter' call).

Rutas con parámetros: getPrerenderParams

Para prerenderizar departamentos/:id, Angular necesita saber qué valores de id existen. getPrerenderParams devuelve un objeto por cada página que debe generarse. Se ejecuta durante el build, y en él puedes usar inject, pero debe llamarse antes de cualquier await.

⚠️ Importante: Como getPrerenderParams se ejecuta al compilar, la API tiene que estar arrancada durante ng build. Esto afecta también a toda ruta prerenderizada que pida datos. En un sistema de integración continua significa levantar la API antes de compilar, o que los datos vengan de otro sitio.

ng build informa de lo que ha generado:

Prerendered 10 static routes.

Cada ruta es un fichero real en dist, y el build deja la lista en prerendered-routes.json:

dist/empresa/browser/index.html
dist/empresa/browser/departamentos/index.html
dist/empresa/browser/departamentos/10/index.html
dist/empresa/browser/departamentos/20/index.html
dist/empresa/browser/departamentos/30/index.html

¿Y si piden un id que no se generó? PrerenderFallback

Con tamano=3 solo se han prerenderizado los departamentos 10, 20 y 30. ¿Qué pasa con el 40? Lo decide fallback:

Valor Qué hace con un parámetro no generado
PrerenderFallback.Server (por defecto) Lo renderiza en el servidor en ese momento
PrerenderFallback.Client Entrega la aplicación vacía y lo renderiza el navegador
PrerenderFallback.None No lo atiende, y la petición sigue hasta el 404 de Express

Comprobar el resultado con curl

Esta tabla resume las respuestas reales del servidor compilado. Las versiones 22 y 21 se comportan igual en todas estas filas.

Ruta Modo Estado Cabecera destacada <h1> del HTML
/ Prerender 200 etag Portal EMPRESA
/departamentos/10 Prerender 200 etag DIRECCIÓN
/departamentos/40 Fallback a Server 200 Sin etag CONTABILIDAD
/empleados Server 200 cache-control: public, max-age=60 Empleados
/antiguo Redirección 302 location: /empleados Sin cuerpo
/noticias-viejas Server con status 410   Noticia retirada
/panel Client 200   Sin <h1> (HTML vacío)

Fíjate en el etag: es la señal de que esa respuesta sale de un fichero prerenderizado.

La redirección no se ha escrito en app.routes.server.ts: viene de un redirectTo normal en app.routes.ts. En modo Server, Angular la convierte en una redirección HTTP de verdad. En Prerender, la documentación indica que se resuelve con una etiqueta <meta http-equiv="refresh">.

💡 Consejo: curl es la mejor herramienta para probar SSR, porque enseña lo que recibe un robot, sin JavaScript de por medio. Si quieres ver el <h1> de una ruta: curl -s http://localhost:4000/empleados | grep "<h1".

⚠️ Importante: No pongas status: 404 en la entrada **. Se aplica a todas las rutas sin entrada propia, y también a las redirecciones. Con antiguo en app.routes.ts, el build de este módulo falló así:

ERROR  The '404' status code is not a valid redirect response code. Please use one of the following redirect response codes: 301, 302, 303, 307, 308.

Para responder 404 en las URL inexistentes, fija el estado en el componente de la ruta comodín: es el ejercicio 2.

Código de estado que depende de los datos

status en una ruta de servidor es fijo. Pero ¿qué devuelves si piden /departamentos/999, que no existe? La ruta es válida; lo que falta es el dato. Para eso está el token RESPONSE_INIT, que explicaremos en la sección 9:

// src/app/paginas/detalle-departamento.ts
import { httpResource } from '@angular/common/http';
import { Component, effect, inject, input, RESPONSE_INIT } from '@angular/core';
import { API, Departamento } from '../modelos';

@Component({
  selector: 'app-detalle-departamento',
  template: `
    @if (departamento.hasValue()) {
      <h1>{{ departamento.value().nombre }}</h1>
      <p>Sede: {{ departamento.value().localidad }}</p>
    } @else if (departamento.error()) {
      <h1>Departamento no encontrado</h1>
    }
  `,
})
export class DetalleDepartamento {
  readonly id = input.required<string>();
  private readonly respuesta = inject(RESPONSE_INIT, { optional: true });

  protected readonly departamento = httpResource<Departamento>(
    () => `${API}/departamentos/${this.id()}`,
  );

  constructor() {
    effect(() => {
      if (this.departamento.error() && this.respuesta) {
        this.respuesta.status = 404;
      }
    });
  }
}

El id llega como input gracias a withComponentInputBinding(), que viste en el módulo 10. Con este componente, /departamentos/999 responde 404 y su <h1> dice «Departamento no encontrado». Sin el effect, un robot recibiría un 200 con un texto de error y lo indexaría como página válida. Este falso acierto se llama soft 404.


5. Hidratación: conectar la aplicación al HTML

El navegador ya pinta el HTML del servidor, pero esa página está inerte. Los botones no hacen nada, porque en HTML no viaja ningún manejador de eventos: (click)="sumar()" solo existe en el JavaScript. Hay que arrancar Angular en el navegador, y hay dos formas de hacerlo.

La forma ingenua sería tirar el HTML y volver a crearlo todo. Funcionaba así en versiones antiguas, y provocaba un parpadeo: el usuario veía la página, desaparecía y reaparecía.

La hidratación lo hace mejor. Angular ejecuta los componentes en el navegador, pero en vez de crear nodos del DOM nuevos busca los que ya existen, los empareja con la plantilla y les conecta los eventos.

Flujo en siete pasos: el servidor ejecuta componentes, serializa el HTML con anotaciones y añade el estado; el navegador pinta, descarga el JS, empareja nodos y conecta los eventos

Figura 3. Del servidor al navegador. El paso clave es el 6: Angular reutiliza los nodos en lugar de crearlos.

Para saber qué nodo corresponde a cada parte de la plantilla, el servidor deja pistas en el HTML. Este es un fragmento real de la respuesta de /hora:

<app-hora ngh="0"><h1>Hora</h1><p id="numero">0.787135</p></app-hora>

El atributo ngh apunta a una descripción de esa vista dentro del bloque <script id="ng-state" type="application/json">, que va al final del HTML. Ahí también viajan los datos transferidos que verás en la sección 8.

Qué activa provideClientHydration()

Función Efecto
provideClientHydration() Hidratación, caché de transferencia HTTP e hidratación incremental (esta última, solo en 22)
withNoHttpTransferCache() Desactiva la caché de transferencia HTTP
withHttpTransferCacheOptions({...}) Configura esa caché (sección 8)
withI18nSupport() Hidrata también los bloques i18n, que por defecto se omiten y se vuelven a crear
withEventReplay() Event replay (sección 7)
withNoIncrementalHydration() Desactiva la incremental (sección 6). Solo en 22

⚠️ Importante: El componente se ejecuta dos veces: una en el servidor y otra en el navegador. El constructor corre en los dos sitios. Eso explica buena parte de los errores de este tema: un console.log del constructor sale en dos consolas, y un Math.random() da dos valores distintos.

Para comprobar que la hidratación funciona, mira la consola del navegador. En modo de desarrollo (ng serve, o una compilación con ng build --configuration development), Angular escribe un resumen. Este es el real de /empleados:

Angular hydrated 2 component(s) and 52 node(s), 0 component(s) were skipped. 0 defer block(s) were configured to use incremental hydration. Learn more at https://v22.angular.dev/guide/hydration.

Página de empleados ya hidratada, con la cabecera azul de EMPRESA y una tabla de cinco personas

Figura 4. La misma página de la figura 1, ya hidratada. A simple vista no cambia nada: eso es justo lo que se busca.


6. Hidratación incremental 🔄 v22.0

🔄 Cambia en Angular 22.0. provideClientHydration() ya incluye la hidratación incremental. En la LTS (Angular 21) hay que pedirla. Si usas la LTS: escribe provideClientHydration(withIncrementalHydration()); sin esa opción, los bloques hydrate se ignoran sin ningún aviso al compilar y su contenido no llega en el HTML del servidor.

Hidratar toda la página de golpe tiene un coste: hay que descargar todo el JavaScript y ejecutar todos los componentes, incluidos los que el usuario quizá nunca toque. La hidratación incremental deja partes de la página deshidratadas: el HTML ya está y se ve, pero su código no se ejecuta hasta que hace falta.

Se apoya en lo que ya conoces de @defer (módulo 23), añadiendo disparadores con la palabra hydrate:

// src/app/incremental/pagina.ts
import { Component } from '@angular/core';
import { AvisoLegal, BloquePadre, Comentarios, ContadorVisitas, Relacionados } from './piezas';

@Component({
  selector: 'app-pagina-incremental',
  imports: [Comentarios, Relacionados, ContadorVisitas, AvisoLegal, BloquePadre],
  template: `
    <h1>Noticia de la empresa</h1>
    <p>El artículo principal llega como HTML estático.</p>

    @defer (hydrate on interaction) {
      <app-comentarios />
    } @placeholder {
      <p>Aquí irán los comentarios</p>
    }

    @defer (hydrate on timer(600ms)) {
      <app-contador-visitas />
    } @placeholder {
      <p>Visitas</p>
    }

    @defer (hydrate on hover) {
      <app-bloque-padre />
    } @placeholder {
      <p>Padre</p>
    }

    <div style="height: 1800px"></div>

    @defer (hydrate on viewport) {
      <app-relacionados />
    } @placeholder {
      <p>Relacionados</p>
    }

    @defer (hydrate never) {
      <app-aviso-legal />
    } @placeholder {
      <p>Legal</p>
    }
  `,
})
export class PaginaIncremental {}

Página con cuatro bloques deshidratados y el disparador de cada uno: interaction, timer, viewport y never

Figura 5. Cada bloque se hidrata por su cuenta, cuando se cumple su disparador.

El usuario no nota nada: todos los bloques se ven desde el primer momento, aunque ninguno se haya hidratado todavía.

Captura de la noticia con los bloques de comentarios, visitas, bloque padre y valoraciones ya visibles

Figura 6. La página de ejemplo en el navegador. El contenido de los bloques @defer ya venía en el HTML.

Disparador Se hidrata cuando…
hydrate on idle El navegador está libre (requestIdleCallback). Admite un máximo: idle(500)
hydrate on viewport El bloque entra en pantalla
hydrate on interaction Hay un click o keydown en el bloque
hydrate on hover Hay un mouseover o focusin en el bloque
hydrate on immediate Termina de renderizarse el contenido no diferido
hydrate on timer(500ms) Pasa ese tiempo
hydrate when condicion() La expresión pasa a ser verdadera
hydrate never Nunca: se queda como HTML estático

Varias reglas importantes:

  • hydrate solo actúa en la carga inicial, sobre lo que llegó del servidor. Si luego el usuario navega a esa ruta dentro de la aplicación, se usan los disparadores normales. Por eso puedes combinarlos: @defer (on viewport; hydrate on hover). El primero rige en la navegación por el cliente y el segundo, en la carga inicial.
  • @placeholder no se ve en la carga inicial, porque el contenido real ya está en el HTML. Sigue siendo necesario para la navegación por el cliente.
  • Con varios disparadores gana el primero que se cumpla. Se separan con ;, como en @defer.
  • hydrate when solo sirve en el bloque deshidratado superior. La condición vive en el padre, que debe existir antes de evaluarla. Por ejemplo, un botón ya hidratado cambia una señal listo y @defer (hydrate when listo()) { <app-valoraciones /> } despierta en ese momento; así ocurrió al probarlo.
  • hydrate never afecta a todo su subárbol. Ningún hydrate de dentro llegará a dispararse.
  • Los bloques anidados se hidratan de fuera hacia dentro. No se puede hidratar un hijo sin hidratar antes a su padre.

Medida real en 22 y en la LTS

Hemos compilado exactamente la misma página cuatro veces y la hemos medido en Chromium con el servidor de producción. Cada componente deja un mensaje en la consola al crearse en el navegador. Después se pulsa «Publicar» dentro de los comentarios.

Medida 22 provideClientHydration() 22 withNoIncrementalHydration() 21 provideClientHydration() 21 withIncrementalHydration()
Los comentarios vienen en el HTML Sí No: placeholder No: placeholder Sí
Tamaño del HTML 4 770 B 2 422 B 2 268 B 4 615 B
Atributos jsaction en el HTML 9 0 0 9
Componentes creados en 1,5 s, sin tocar nada 1 de 6 6 de 6 6 de 6 1 de 6
Se crea AvisoLegal (hydrate never) Nunca Sí Sí Nunca
Comentarios tras pulsar «Publicar» (de 3 a…) 4 4 4 4
JavaScript descargado en total 352,7 KB 352,7 KB 316,6 KB 333,4 KB

Esto es lo que dicen esos datos:

  • La columna clave es la tercera. Con la misma plantilla y sin ningún aviso, en Angular 21 sin withIncrementalHydration() el bloque @defer se comporta como uno normal: el servidor envía el placeholder. Compila, funciona en el navegador y parece correcto, pero el contenido ha desaparecido del HTML. Un robot no verá los comentarios.
  • La incremental cambia dos cosas a la vez: el contenido llega en el HTML (por eso pesa el doble) y los componentes no se ejecutan hasta su disparador.
  • Solo uno de los seis componentes se creó en el primer segundo y medio: ContadorVisitas, por su timer(600ms). Al pulsar en los comentarios se creó Comentarios; al pasar el ratón, BloquePadre, y al bajar hasta el final, Relacionados. AvisoLegal no llegó a crearse nunca.
  • El nodo se conserva. La sección de comentarios que había en el HTML es el mismo objeto del DOM antes y después de hidratar.

Este es el registro real del navegador en la variante de 22, en orden de aparición:

[creado en el navegador] ContadorVisitas   <- 600 ms
[creado en el navegador] Comentarios       <- clic en Publicar
[creado en el navegador] BloquePadre       <- ratón encima
[creado en el navegador] Relacionados      <- bajar hasta el final

⚠️ Importante: El ahorro de JavaScript depende de cómo esté repartido tu código. En esta medida, el total descargado no bajó con la incremental (la última fila de la tabla), porque los seis componentes están en el mismo fichero piezas.ts y Angular los empaqueta en un único trozo de 32 KB, que se descargó entero al dispararse el primer bloque. Si AvisoLegal estuviera en su propio fichero y nadie más lo usara, su código no se descargaría nunca. Para aprovechar la hidratación incremental, separa los componentes diferidos en ficheros propios.

Una propiedad curiosa: hydrate también ahorra trabajo cuando el código ya se ha descargado. Aunque el trozo con todos los componentes llegó a los 600 ms, Comentarios y Relacionados no se ejecutaron hasta que hizo falta.

Desactivarla: withNoIncrementalHydration 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: no hace falta, porque allí la hidratación incremental está desactivada mientras no añadas withIncrementalHydration().

import { provideClientHydration, withNoIncrementalHydration } from '@angular/platform-browser';

provideClientHydration(withNoIncrementalHydration());

Con esta opción, los bloques hydrate se comportan como en la segunda columna de la tabla. ng update a la versión 22 aplica una migración llamada incremental-hydration, que añade esta opción a los proyectos que ya usaban provideClientHydration() sin withIncrementalHydration(). Así conservan su comportamiento antiguo. Si actualizas un proyecto desde la LTS y quieres la novedad, quita esa opción.

En sentido contrario, withIncrementalHydration() sigue existiendo en 22, pero está obsoleta: el .d.ts anuncia su retirada en la versión 24.


7. Event replay: que no se pierda ningún clic 🔄 v22.0

🔄 Cambia en Angular 22.0. Event replay va activado con provideClientHydration(), porque depende de él la hidratación incremental. En la LTS (Angular 21) no lo está. Si usas la LTS: añade withEventReplay(), que ng add ya escribe por ti, o withIncrementalHydration(), que también lo activa.

Entre que el HTML se ve y Angular termina de hidratar hay un rato en el que los botones parecen funcionar y no hacen nada. En una mala conexión pueden ser varios segundos. Si el usuario pulsa ahí, el clic se pierde.

Event replay soluciona esto. Un pequeño script incluido en el HTML escucha los eventos desde el primer momento y los guarda en una cola; al terminar la hidratación, Angular los reproduce en orden sobre los componentes ya conectados. Los atributos jsaction de la tabla anterior son las marcas que usa para saber qué elementos tienen manejadores.

Para medirlo, retrasamos 1,5 s la descarga de los scripts y pulsamos dos veces un botón antes de que llegaran. El componente es un simple contador:

// src/app/paginas/replay.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-replay',
  template: `
    <h1>Event replay</h1>
    <button id="sumar" type="button" (click)="sumar()">Sumar</button>
    <output id="total">{{ total() }}</output>
  `,
})
export class Replay {
  protected readonly total = signal(0);
  protected sumar() {
    this.total.update((n) => n + 1);
  }
}
Configuración Total antes de hidratar Total después de hidratar
22, provideClientHydration() 0 2
22, withNoIncrementalHydration() 0 0
22, withNoIncrementalHydration() y withEventReplay() 0 2
21, provideClientHydration() 0 0
21, withIncrementalHydration() 0 2

Dos consecuencias:

  • Si desactivas la incremental en 22, pierdes también el replay, salvo que lo pidas: provideClientHydration(withNoIncrementalHydration(), withEventReplay()).
  • Durante esa espera, el 0 sigue en pantalla. Los clics se han guardado, pero el usuario no recibe respuesta hasta el final. Event replay evita que se pierda la acción, no que la página parezca lenta.

8. No pedir dos veces lo mismo

Si el componente se ejecuta en el servidor y en el navegador, ¿se pide dos veces a la API? Con HttpClient, no.

El servidor hace el GET real y guarda la respuesta en ng-state; el navegador la lee de ahí y no hace la segunda petición

Figura 7. La caché de transferencia envía la respuesta dentro del HTML.

La caché de transferencia de HttpClient

Viene activada con provideClientHydration(). Mientras el servidor renderiza, HttpClient guarda las respuestas en TransferState, un almacén de pares clave-valor que viaja en el bloque ng-state. Al hidratar, la misma petición se responde desde ahí sin tocar la red.

Lo comprobamos contando las peticiones que hace el navegador a la API al abrir cada ruta. Todas pidieron datos en el servidor:

Ruta Cómo pide los datos Peticiones del navegador (22) Peticiones del navegador (21)
/empleados httpResource 0 0
/departamentos httpResource, prerenderizada 0 0
/clientes httpResource, excluida con filter 1 1
/transfer fetch con TransferState a mano 0 0
/recurso-sin-id resource con fetch 1 1
/recurso-id resource con id 0 No existe id (1 sin él)

httpResource usa HttpClient, así que se beneficia de la caché. Un fetch directo no pasa por él, y por eso se repite.

Por defecto solo se guardan GET y HEAD, y no las peticiones con cabeceras Authorization, Proxy-Authorization o Cookie. Tampoco se guardan las cabeceras de la respuesta. Puedes cambiarlo:

// src/app/app.config.ts
import { provideClientHydration, withHttpTransferCacheOptions } from '@angular/platform-browser';

provideClientHydration(
  withHttpTransferCacheOptions({
    filter: (peticion) => !peticion.url.includes('/clientes'),
    includeHeaders: ['ETag'],
    includePostRequests: false,
    includeRequestsWithAuthHeaders: false,
  }),
);
Opción Efecto Cuidado
filter Decide petición a petición si se guarda Úsalo para excluir datos que deban ser recientes
includeHeaders Cabeceras de la respuesta que se guardan Nunca incluyas tokens: el HTML es público
includePostRequests Guarda también POST Solo si son lecturas, como una consulta GraphQL
includeRequestsWithAuthHeaders Guarda peticiones autenticadas Solo si la respuesta no depende del usuario

También se puede decidir en una petición concreta, y funciona igual en HttpClient y en httpResource:

http.get(`${API}/empleados`, { transferCache: false });
http.get(`${API}/empleados`, { transferCache: { includeHeaders: ['ETag'] } });

Si el servidor llama a la API por una dirección interna y el navegador por la pública, las URL no coinciden y la caché no acierta. El token HTTP_TRANSFER_CACHE_ORIGIN_MAP declara que ambos orígenes son equivalentes y debe proporcionarse solo en el servidor:

// src/app/app.config.server.ts
import { HTTP_TRANSFER_CACHE_ORIGIN_MAP } from '@angular/common/http';

{ provide: HTTP_TRANSFER_CACHE_ORIGIN_MAP, useValue: { 'http://api-interna:3000': 'https://api.empresa.es' } }

⚠️ Importante: Todo lo que entra en TransferState aparece en claro dentro del HTML. Cualquiera puede leerlo con «Ver código fuente». Nunca metas ahí secretos, y no transfieras datos de un usuario a una página que pueda guardarse en una caché compartida.

TransferState a mano

Para datos que no pasan por HttpClient (un fetch, un SDK, una lectura de disco) usas el almacén directamente:

// src/app/paginas/transfer-manual.ts
import { isPlatformServer } from '@angular/common';
import {
  Component,
  inject,
  makeStateKey,
  PendingTasks,
  PLATFORM_ID,
  signal,
  TransferState,
} from '@angular/core';
import { API, Empleado, Pagina } from '../modelos';

const EMPLEADOS = makeStateKey<Empleado[]>('empleados-fetch');

@Component({
  selector: 'app-transfer-manual',
  template: `
    <h1>TransferState a mano</h1>
    <ul>
      @for (e of empleados(); track e.empId) {
        <li>{{ e.nombre }}</li>
      }
    </ul>
  `,
})
export class TransferManual {
  private readonly estado = inject(TransferState);
  private readonly tareas = inject(PendingTasks);
  private readonly enServidor = isPlatformServer(inject(PLATFORM_ID));
  protected readonly empleados = signal<Empleado[]>([]);

  constructor() {
    // En el navegador, los datos ya vienen en el HTML
    const guardados = this.estado.get(EMPLEADOS, null);
    if (guardados) {
      this.empleados.set(guardados);
      this.estado.remove(EMPLEADOS);
      return;
    }
    this.tareas.run(() => this.cargar());
  }

  private async cargar() {
    const respuesta = await fetch(`${API}/empleados?tamano=3`);
    const pagina = (await respuesta.json()) as Pagina<Empleado>;
    this.empleados.set(pagina.contenido);
    if (this.enServidor) {
      this.estado.set(EMPLEADOS, pagina.contenido);
    }
  }
}

estado.remove borra la entrada al usarla. Sin eso, si el usuario navega fuera y vuelve, seguiría viendo los datos viejos del servidor. PendingTasks se explica en la sección 10: es lo que obliga al servidor a esperar a la promesa.

resource con id 🆕 v22.0

🆕 Desde Angular 22.0. La opción id de resource no existe en la LTS (Angular 21), donde da un error TS2769 ('id' does not exist in type). Si usas la LTS: usa httpResource, que ya pasa por la caché de HttpClient, o guarda el valor con TransferState, como en el ejemplo anterior.

Un resource con fetch se ejecuta dos veces, como has visto en la tabla. Desde la versión 22, basta con ponerle nombre para que Angular guarde su valor en TransferState y lo recupere al hidratar:

// src/app/paginas/recurso-con-id.ts
import { Component, resource } from '@angular/core';
import { API, Empleado, Pagina } from '../modelos';

@Component({
  selector: 'app-recurso-con-id',
  template: `
    <h1>Recurso con id</h1>
    @if (empleados.hasValue()) {
      <p>{{ empleados.value().total }} empleados</p>
    }
  `,
})
export class RecursoConId {
  protected readonly empleados = resource({
    id: 'empleados-resumen',
    loader: async () => {
      const respuesta = await fetch(`${API}/empleados?tamano=1`);
      return (await respuesta.json()) as Pagina<Empleado>;
    },
  });
}

El id debe ser el mismo en servidor y cliente, y único en toda la aplicación. La opción está declarada en BaseResourceOptions, y dentro de Angular se guarda en un campo privado llamado transferCacheKey: si ves ese nombre en un artículo o en el código fuente, se refiere a esto. En tu código, la opción es id.


9. La petición y la respuesta: REQUEST, RESPONSE_INIT, REQUEST_CONTEXT

Dentro de un componente puedes acceder a la petición HTTP que se está atendiendo. Los tres tokens se importan de @angular/core:

Token Tipo Para qué sirve
REQUEST Request \| null Leer URL, cabeceras y cookies
RESPONSE_INIT ResponseInit \| null Fijar estado y cabeceras de la respuesta
REQUEST_CONTEXT unknown Datos extra que server.ts pasa como segundo argumento de handle

Valen null en el navegador y durante el build, también al prerenderizar, porque ahí no hay ninguna petición. Por eso se inyectan con { optional: true } y se comprueban siempre.

// src/app/paginas/info-peticion.ts
import {
  Component,
  inject,
  makeStateKey,
  REQUEST,
  RESPONSE_INIT,
  TransferState,
} from '@angular/core';

const IDIOMA = makeStateKey<string>('idioma');

@Component({
  selector: 'app-info-peticion',
  template: `
    <h1>Tu petición</h1>
    <p>Idioma preferido: {{ idioma }}</p>
  `,
})
export class InfoPeticion {
  private readonly peticion = inject(REQUEST, { optional: true });
  private readonly respuesta = inject(RESPONSE_INIT, { optional: true });
  private readonly estado = inject(TransferState);
  protected readonly idioma: string;

  constructor() {
    if (this.peticion) {
      const cabecera = this.peticion.headers.get('accept-language') ?? 'sin indicar';
      this.estado.set(IDIOMA, cabecera.split(',')[0]);
    }
    if (this.respuesta) {
      this.respuesta.headers = { 'X-Generado-Por': 'Angular SSR' };
    }
    this.idioma = this.estado.get(IDIOMA, 'desconocido');
  }
}

Observa el truco: como REQUEST es null en el navegador, el valor leído en el servidor se envía por TransferState. Si no lo hicieras, el servidor escribiría es-ES y el navegador lo cambiaría por otro texto al hidratar. Con curl -H "Accept-Language: es-ES,es;q=0.9", la respuesta real es esta:

<app-info-peticion ngh="0"><h1>Tu petición</h1><p id="idioma">Idioma preferido: es-ES</p></app-info-peticion>

La cabecera X-Generado-Por: Angular SSR también llega en la respuesta.

⚠️ Importante: Estos tokens no funcionan en rutas Prerender, ya que ahí no hay petición. Si una página necesita leer una cookie o el idioma, debe ser Server o Client.


10. Cuidados al escribir código para el servidor

En Node no existe el navegador. Además, un mismo proceso atiende a muchos usuarios a la vez. Las dos cosas obligan a cambiar algunos hábitos.

window y document no existen

@Component({
  selector: 'app-ventana-mal',
  template: `<p>Ancho: {{ ancho }}</p>`,
})
export class VentanaMal {
  protected readonly ancho = window.innerWidth; // falla en el servidor
}

La consola del servidor muestra esto, y el usuario recibe el 404 de Express con una página de 150 bytes, no un 500:

ERROR ReferenceError: window is not defined

Es engañoso: la ruta existe, pero el fallo al crear el componente hace que Angular no genere respuesta. Si un 404 aparece solo con SSR, mira primero la consola del servidor.

La solución es no tocar el navegador hasta estar en él. afterNextRender, que conociste en el módulo 03, se ejecuta solo en el navegador:

// src/app/paginas/ventana-bien.ts
import { afterNextRender, Component, signal } from '@angular/core';

@Component({
  selector: 'app-ventana-bien',
  template: `
    <h1>Ventana</h1>
    <p>Ancho: {{ ancho() ?? 'calculando' }}</p>
  `,
})
export class VentanaBien {
  protected readonly ancho = signal<number | undefined>(undefined);

  constructor() {
    afterNextRender(() => this.ancho.set(window.innerWidth));
  }
}

El HTML del servidor dice Ancho: calculando, y el navegador lo cambia a Ancho: 1000 tras hidratar. No hay desajuste, porque en el momento de hidratar ambos lados tienen el mismo valor.

Quieres… Usa
Acceder al DOM o a window afterNextRender o afterEveryRender
Leer el documento en cualquier plataforma inject(DOCUMENT), de @angular/common
Saber en qué plataforma estás isPlatformBrowser(inject(PLATFORM_ID)), solo como último recurso
Dar una implementación distinta en el servidor Un proveedor en app.config.server.ts

💡 Consejo: Antes de comprobar la plataforma con if, piensa si puedes sobrescribir un servicio. Si ALMACEN es un InjectionToken que usa localStorage, en app.config.server.ts le das una versión que no hace nada, y los componentes no se enteran. El módulo 08 explica cómo.

El servidor solo espera lo que conoce

¿Cuándo da Angular por terminada una página en el servidor? Cuando la aplicación está estable. Sin Zone.js, Angular no vigila cualquier setTimeout: solo espera a las tareas que conoce. HttpClient y los recursos se registran solos. Lo tuyo, no:

// src/app/paginas/espera.ts
import { Component, inject, PendingTasks, signal } from '@angular/core';

@Component({
  selector: 'app-espera',
  template: `
    <h1>Espera</h1>
    <p>Sin tarea: {{ sinTarea() }}</p>
    <p>Con tarea: {{ conTarea() }}</p>
  `,
})
export class Espera {
  protected readonly sinTarea = signal('pendiente');
  protected readonly conTarea = signal('pendiente');

  constructor() {
    setTimeout(() => this.sinTarea.set('listo'), 800);

    inject(PendingTasks).run(async () => {
      await new Promise((resolver) => setTimeout(resolver, 100));
      this.conTarea.set('listo');
    });
  }
}

Este es el HTML real que devuelve el servidor:

<p id="sin-tarea">Sin tarea: pendiente</p>
<p id="con-tarea">Con tarea: listo</p>

El servidor esperó los 100 ms de la tarea registrada y envió la página: no se enteró del temporizador de 800 ms. PendingTasks.run mantiene la aplicación inestable hasta que termina la promesa.

Cuidado con las falsas alegrías: en una prueba anterior pusimos el mismo retardo en los dos, y ambos salieron listo, porque la tarea registrada mantuvo vivo el renderizado el tiempo justo. El código sin registrar funciona por casualidad, hasta que cambian los tiempos.

Nada de estado compartido entre usuarios

En un proceso de Node, las variables de un módulo viven mientras viva el servidor y las comparten todos los usuarios. Cada petición, en cambio, crea su propia aplicación con su propio inyector. Esta página cuenta visitas de las dos maneras:

// src/app/paginas/fuga.ts
import { Component, inject, Injectable } from '@angular/core';

let visitasGlobal = 0;

@Injectable({ providedIn: 'root' })
export class Contador {
  visitas = 0;
}

@Component({
  selector: 'app-fuga',
  template: `
    <h1>Fuga</h1>
    <p>Variable de módulo: {{ modulo }}</p>
    <p>Servicio raíz: {{ servicio }}</p>
  `,
})
export class Fuga {
  protected readonly modulo = ++visitasGlobal;
  protected readonly servicio = ++inject(Contador).visitas;
}

Tres peticiones seguidas con curl, resultado real en 22 y 21:

Variable de módulo: 1    Servicio raíz: 1
Variable de módulo: 2    Servicio raíz: 1
Variable de módulo: 3    Servicio raíz: 1

La variable de módulo acumula: si en vez de un contador fuera usuarioActual, una persona vería los datos de otra. El servicio empieza de cero en cada petición, porque cada una tiene su inyector raíz. Regla práctica: el estado va en servicios, nunca en variables de módulo. Y ojo con los proveedores: un objeto en useValue se comparte entre peticiones; para un valor por petición, usa useFactory.

Respuestas grandes: maxResponseBodySize 🆕 v22.0

🆕 Desde Angular 22.0. No existe en la LTS (Angular 21), donde provideServerRendering no admite opciones y las respuestas de fetch en el servidor no tienen este límite. Si usas la LTS: limita el tamaño desde la API, por ejemplo con paginación.

En 22, el HttpClient del servidor rechaza con el error NG02825 las respuestas mayores de 1 MB, para que una API que devuelve demasiado no agote la memoria del proceso. Se cambia en el primer argumento de provideServerRendering:

provideServerRendering({ maxResponseBodySize: 2 * 1024 * 1024 }, withRoutes(serverRoutes));

Antes de subirlo, plantéate si necesitas renderizar tantos datos en el servidor. Cuanto más alto, más memoria y más riesgo de denegación de servicio.

Otros cuidados

  • URL relativas al prerenderizar. Al compilar no hay petición, así que http.get('/api/empleados') no tiene host del que tomar la URL. Usa una dirección absoluta.
  • Secretos. server.ts se ejecuta en tu máquina, pero los componentes también se envían al navegador. No pongas una clave en un componente aunque «solo se use en el servidor».
  • Prueba la compilación real. Antes de desplegar, compila y arranca el servidor con node: errores como el 400 de allowedHosts salen ahí.
  • Temporizadores largos. Un setInterval en un constructor también se ejecuta en el servidor. Crea esos temporizadores en afterNextRender.

11. Errores de hidratación

La hidratación solo funciona si el DOM que llega es el que Angular espera. Si no coincide, en modo de desarrollo lo avisa con un error de la familia NG05xx.

HTML que el navegador corrige

El navegador no respeta siempre el HTML que recibe: lo arregla al interpretarlo. Es lo que ocurre con esta tabla sin <tbody>:

@Component({
  selector: 'app-tabla-mal',
  template: `
    <h1>Tabla sin tbody</h1>
    <table>
      <tr>
        <td>Ana</td>
      </tr>
    </table>
  `,
})
export class TablaMal {}

El navegador inserta un <tbody> que no estaba en tu plantilla. Al hidratar, Angular busca un <tr> y encuentra otra cosa. Este es el error real, recortado:

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

Angular expected this DOM:

  <table>
    <tr>…</tr>  <-- AT THIS LOCATION
    …
  </table>

Actual DOM is:

  <table>
    <tbody>…</tbody>  <-- AT THIS LOCATION
  </table>

La solución es escribir siempre el <tbody> en la plantilla. El mensaje dice qué esperaba, qué encontró y en qué componente, así que léelo con calma: casi siempre basta para localizar el fallo.

Otros casos clásicos: un <div> dentro de un <p>, y un <a> dentro de otro <a>.

Ramas distintas según la plataforma

@Component({
  selector: 'app-plataforma-mal',
  template: `
    <h1>Rama por plataforma</h1>
    @if (navegador) {
      <p>Estoy en el navegador</p>
    } @else {
      <div>Estoy en el servidor</div>
    }
  `,
})
export class PlataformaMal {
  protected readonly navegador = isPlatformBrowser(inject(PLATFORM_ID));
}

Aquí sí cambia la estructura y, sin embargo, Angular no da ningún error. En un bloque @if, el servidor anota qué rama renderizó; si en el navegador toca otra, la descarta y crea la nueva. Resultado real, sin nada en la consola:

HTML del servidor:  Estoy en el servidor
DOM tras hidratar:  Estoy en el navegador

Que no haya error no significa que esté bien. El usuario ve un contenido, y al instante, otro; además, se pierde la reutilización del DOM en esa parte. Por eso la guía de Angular desaconseja usar isPlatformBrowser para decidir qué se pinta.

La solución es que el primer render sea igual en los dos lados y que la diferencia llegue después, con afterNextRender y una señal, como en VentanaBien.

Valores que cambian entre servidor y cliente

¿Qué pasa si el texto es distinto pero la estructura es igual?

@Component({
  selector: 'app-hora',
  template: `
    <h1>Hora</h1>
    <p>{{ numero }}</p>
  `,
})
export class Hora {
  protected readonly numero = Math.random().toFixed(6);
}

Resultado real, sin ningún error en la consola:

HTML del servidor:  0.787135
DOM tras hidratar:  0.234130

La estructura coincide, así que Angular reutiliza el nodo y, al actualizarlo, escribe el valor nuevo. No es un error, pero el usuario ve cambiar el texto al cargar la página. Con fechas, precios en otra zona horaria o datos aleatorios, la solución es calcular el valor una sola vez y transferirlo con TransferState.

El último recurso: ngSkipHydration

Si una biblioteca de terceros manipula el DOM (un mapa, un gráfico con D3) y no puedes cambiarla, puedes excluir ese componente:

@Component({
  selector: 'app-saltar-pagina',
  imports: [Saltar],
  template: `
    <h1>ngSkipHydration</h1>
    <app-saltar ngSkipHydration />
  `,
})
export class SaltarPagina {}

También se puede indicar en host: { ngSkipHydration: 'true' }. El componente y sus hijos se destruyen y se vuelven a crear, así que pierdes la ventaja de reutilizar el DOM y se puede notar un parpadeo. Solo vale en el elemento anfitrión de un componente, no en un <div>.

Tabla de diagnóstico

Síntoma Causa probable Solución
400 en todas las rutas, tras ng build Falta allowedHosts Declara el host (sección 3)
En 21, el HTML llega vacío en todas las rutas de servidor También falta allowedHosts Declara el host y compruébalo con curl
404 de Express en una ruta que existe Excepción en el servidor, como window is not defined Mira la consola del servidor
NG0500, NG0501… El DOM no coincide Revisa HTML inválido y ramas por plataforma
La API recibe dos peticiones iguales fetch directo, o URL distinta en servidor y cliente HttpClient, TransferState, id o HTTP_TRANSFER_CACHE_ORIGIN_MAP
El HTML no trae un dato Tarea asíncrona que el servidor no espera PendingTasks
El HTML trae el placeholder de un @defer en lugar del contenido En 21: falta withIncrementalHydration(). En 22: se ha desactivado Sección 6
NG0506 La aplicación no se estabiliza en 10 s provideStabilityDebugging() en app.config.ts para investigarlo
Texto que parpadea al cargar Valor distinto en cada lado Transfiérelo con TransferState
Prerendered 0 static routes o fallo en ng build La API no está arrancada al compilar Arráncala, o cambia esa ruta a Server

12. Ejercicios

Para todos ellos, parte de un proyecto con SSR añadido y con la API EMPRESA arrancada. Recuerda declarar el host si compilas: NG_ALLOWED_HOSTS=localhost.

Ejercicio 1: Elige el modo de cada ruta

Una tienda tiene estas rutas. Decide el RenderMode de cada una y escribe app.routes.server.ts:

  • '': portada que cambia una vez al mes.
  • productos/:id: 20 productos, con descripción que cambia poco. Prerenderiza los cinco más caros; el resto, en el servidor.
  • buscar: resultados según la URL.
  • carrito: propio de cada usuario, tras iniciar sesión.
Ver solución
// src/app/app.routes.server.ts
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { PrerenderFallback, RenderMode, ServerRoute } from '@angular/ssr';
import { firstValueFrom } from 'rxjs';
import { API, Pagina, Producto } from './modelos';

export const serverRoutes: ServerRoute[] = [
  { path: '', renderMode: RenderMode.Prerender },
  {
    path: 'productos/:id',
    renderMode: RenderMode.Prerender,
    fallback: PrerenderFallback.Server,
    async getPrerenderParams() {
      const http = inject(HttpClient);
      const pagina = await firstValueFrom(
        http.get<Pagina<Producto>>(`${API}/productos?tamano=5&orden=-precio`),
      );
      return pagina.contenido.map((p) => ({ id: String(p.productoId) }));
    },
  },
  { path: 'buscar', renderMode: RenderMode.Server },
  { path: 'carrito', renderMode: RenderMode.Client },
  { path: '**', renderMode: RenderMode.Server },
];

Con la API de ejemplo se generan cinco páginas: /productos/1, /productos/2, /productos/4, /productos/5 y /productos/12. La búsqueda es Server porque el resultado depende de la URL y no se puede enumerar. El carrito es Client porque depende del usuario: el servidor no aporta nada y una caché podría mezclar datos de personas distintas. fallback podría omitirse, porque Server es su valor por defecto; se escribe para dejar clara la intención.

Ejercicio 2: Un 404 de verdad

En app.routes.ts, la ruta ** muestra el componente NoEncontrado, pero curl -i enseña un 200. Haz que responda 404. Recuerda que status: 404 en la entrada ** de las rutas de servidor no es una buena solución: la sección 4 explica por qué.

Ver solución
// src/app/paginas/no-encontrado.ts
import { Component, inject, RESPONSE_INIT } from '@angular/core';

@Component({
  selector: 'app-no-encontrado',
  template: `
    <h1>Página no encontrada</h1>
    <p>La dirección que buscas no existe.</p>
  `,
})
export class NoEncontrado {
  constructor() {
    const respuesta = inject(RESPONSE_INIT, { optional: true });
    if (respuesta) {
      respuesta.status = 404;
    }
  }
}

Resultado:

$ curl -I http://localhost:4000/no-existe
HTTP/1.1 404 Not Found

Este componente solo se muestra cuando ninguna otra ruta coincide, así que el estado es correcto sin tocar las rutas de servidor. La comprobación if (respuesta) es necesaria, porque el token vale null en el navegador.

Ejercicio 3: Arregla el componente que rompe el servidor

Este componente da un 404 en SSR. Corrígelo para que el HTML del servidor muestre «Tema: claro» y el navegador aplique después la preferencia guardada.

@Component({
  selector: 'app-tema',
  template: `<p>Tema: {{ tema }}</p>`,
})
export class Tema {
  protected readonly tema = localStorage.getItem('tema') ?? 'claro';
}
Ver solución
// src/app/paginas/tema.ts
import { afterNextRender, Component, signal } from '@angular/core';

@Component({
  selector: 'app-tema',
  template: `<p id="tema">Tema: {{ tema() }}</p>`,
})
export class Tema {
  protected readonly tema = signal('claro');

  constructor() {
    afterNextRender(() => {
      this.tema.set(localStorage.getItem('tema') ?? 'claro');
    });
  }
}

El valor inicial es el mismo en ambos lados, así que no hay desajuste. afterNextRender no se ejecuta en el servidor, por lo que localStorage solo se toca en el navegador. Quien tenga guardado oscuro verá un instante «claro» antes del cambio. Si eso molesta, guarda la preferencia en una cookie, léela con REQUEST y transfiérela con TransferState, ya que el servidor sí recibe las cookies.

Ejercicio 4: Datos en el HTML

Cuenta pide datos con un setTimeout que simula una biblioteca lenta. El HTML del servidor siempre muestra cargando. Haz que muestre el dato sin quitar el temporizador.

export class Cuenta {
  protected readonly total = signal('cargando');
  constructor() {
    setTimeout(() => this.total.set('42'), 200);
  }
}
Ver solución
// src/app/paginas/cuenta.ts
import { Component, inject, PendingTasks, signal } from '@angular/core';

@Component({
  selector: 'app-cuenta',
  template: `<p id="total">Total: {{ total() }}</p>`,
})
export class Cuenta {
  protected readonly total = signal('cargando');

  constructor() {
    inject(PendingTasks).run(async () => {
      await new Promise((resolver) => setTimeout(resolver, 200));
      this.total.set('42');
    });
  }
}

PendingTasks.run le dice a Angular que la aplicación no está estable hasta que termine esa promesa. El servidor espera y serializa el HTML con el dato. El temporizador se ejecutará también en el navegador; si la operación fuera cara, añade TransferState para no repetirla.

Ejercicio 5: Diseña la hidratación de una ficha

Una ficha de producto tiene galería, botón de compra, reseñas al final y condiciones de venta. Aplica estas reglas: la galería se activa al acercar el ratón; el botón, al usarlo; las reseñas, al verse, y las condiciones nunca necesitan JavaScript en la carga inicial. Al llegar desde otra ruta, la galería y las reseñas deben cargarse al verse, y el botón de inmediato.

Ver solución
// src/app/paginas/ficha.ts
@Component({
  selector: 'app-ficha',
  imports: [Galeria, BotonCompra, Resenas, Condiciones],
  template: `
    <h1>Portátil UltraBook 14</h1>

    @defer (on viewport; hydrate on hover) {
      <app-galeria />
    } @placeholder {
      <div style="height: 200px">Galería</div>
    }

    @defer (on immediate; hydrate on interaction) {
      <app-boton-compra />
    } @placeholder {
      <button type="button" disabled>Añadir</button>
    }

    @defer (on viewport; hydrate on viewport) {
      <app-resenas />
    } @placeholder {
      <p>Reseñas</p>
    }

    @defer (hydrate never) {
      <app-condiciones />
    } @placeholder {
      <p>Condiciones</p>
    }
  `,
})
export class Ficha {}

on rige en la navegación por el cliente y hydrate on, en la carga inicial. Al pulsar el botón todavía deshidratado, el clic se reproduce y el contador pasa a 1: lo hemos comprobado en el navegador. El placeholder de la galería tiene altura fija para que la página no salte. En Angular 21, esta plantilla exige añadir withIncrementalHydration().

Ejercicio 6: Excluye una ruta de la caché

La lista de clientes debe ser siempre la más reciente, aunque se haya renderizado en el servidor. Configura la caché para que el navegador repita esa petición, y compruébalo en la pestaña Red.

Ver solución
// src/app/app.config.ts
provideClientHydration(
  withHttpTransferCacheOptions({
    filter: (peticion) => !peticion.url.includes('/clientes'),
  }),
);

Con esto, al abrir /clientes el navegador hace GET /api/clientes?tamano=3, mientras que /empleados sigue sin hacer ninguna petición. Es el resultado de la tabla de la sección 8. La alternativa, si solo afecta a una llamada, es http.get(url, { transferCache: false }).

Ejercicio 7: Adapta un proyecto a la LTS

Tienes este app.config.ts en Angular 22 y una página con bloques hydrate. Vas a compilarlo en Angular 21, y quieres conservar el contenido en el HTML y el replay. ¿Qué cambias?

provideClientHydration(),
Ver solución
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser';

provideClientHydration(withIncrementalHydration()),

Con eso basta, porque en la medida de la sección 7 withIncrementalHydration() activó también el replay (de 0 a 2 clics) y no hace falta withEventReplay(). Sin esa opción no hay ningún error ni aviso: el proyecto compila y el HTML pierde el contenido de los bloques. Si además usabas resource con id, cámbialo por httpResource o TransferState.

Ejercicio 8 (propuesto): Sitio totalmente estático

Cambia outputMode a "static" en un proyecto cuyas rutas sean todas Prerender o Client, compila y sirve la carpeta browser con un servidor de ficheros cualquiera. ¿Qué rutas funcionan al recargar la página, y cuáles no? Explica por qué.

Partiendo del ejercicio 3, guarda el tema en una cookie desde el navegador y léela en el servidor con REQUEST, de modo que el primer HTML ya salga con el tema correcto. Ten en cuenta que la ruta no podrá ser Prerender.

Ejercicio 10 (propuesto): Mide tu propia página

Toma una página con varios @defer, separa los componentes en ficheros propios y compara en la pestaña Red el JavaScript descargado en la carga inicial con y sin hydrate never.


Resumen

Concepto API Idea clave
Añadir SSR ng add @angular/ssr Crea server.ts, main.server.ts y dos configuraciones
Modos por ruta ServerRoute, RenderMode Client, Server o Prerender, ruta a ruta
Prerender con parámetros getPrerenderParams Se ejecuta en ng build: la API debe estar arrancada
Parámetro no generado PrerenderFallback Server (por defecto), Client o None
Estado y cabeceras status, headers, RESPONSE_INIT Fijos en la ruta, o según los datos en el componente
Hosts permitidos allowedHosts, NG_ALLOWED_HOSTS Sin ellos, el servidor responde 400 en 22 y HTML vacío en 21
Hidratación provideClientHydration Reutiliza el DOM del servidor en vez de recrearlo
Hidratación incremental hydrate on, hydrate when, hydrate never Por defecto en 22; en 21, withIncrementalHydration()
Desactivarla withNoIncrementalHydration 🆕 v22.0 También desactiva el replay
Event replay withEventReplay Guarda los clics previos a la hidratación
Caché de HttpClient withHttpTransferCacheOptions, transferCache Evita repetir GET en el navegador
Orígenes distintos HTTP_TRANSFER_CACHE_ORIGIN_MAP Iguala la URL interna y la pública
Estado transferido TransferState, makeStateKey Viaja en claro dentro del HTML
resource sin doble carga id 🆕 v22.0 Guarda el valor en TransferState
Petición y respuesta REQUEST, RESPONSE_INIT, REQUEST_CONTEXT null en el navegador y al prerenderizar
Código del navegador afterNextRender Nunca se ejecuta en el servidor
Esperar trabajo propio PendingTasks Sin ello, el servidor no lo espera
Límite de respuesta maxResponseBodySize 🆕 v22.0 1 MB por defecto en fetch del servidor
Excluir un componente ngSkipHydration Último recurso: se recrea desde cero