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/ssry saber para qué sirve cada fichero generado - Elegir el modo de cada ruta con
ServerRoute,RenderModeygetPrerenderParams - Fijar códigos de estado, cabeceras y redirecciones desde la configuración de rutas
- Configurar los hosts permitidos, sin los que el servidor responde
400a 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,
TransferStatey la opciónidderesource - Leer la petición y modificar la respuesta con
REQUESTyRESPONSE_INIT - Escribir componentes que funcionen en un servidor: sin
window, conafterNextRender, conPendingTasksy 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.


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.
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
Servertampoco 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/ssrescribeprovideClientHydration(). En la LTS (Angular 21) escribeprovideClientHydration(withEventReplay()), porque allí event replay no viene activado. Si usas la LTS: conserva esa opción y añadewithIncrementalHydration()si quieres usar los disparadoreshydratede 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 unaResponseestándar de la Web, onullsi Angular no sabe qué hacer con esa petición. En ese caso,next()deja que Express siga y acabe con su404por defecto.- Al ser un Express normal, puedes añadir rutas propias (
app.get('/salud', ...)) antes del paso 2. reqHandleres lo que usa la CLI enng servey al compilar. Por eso sigue exportándose aunque arranques connode.
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 responde400a todo. En la LTS (Angular 21) responde200con la aplicación vacía, de modo que se pierde el SSR sin ningún error visible. Si usas la LTS: declara tambiénallowedHosts; solo con la lista hecha obtendrás el HTML generado en el servidor, y un host desconocido recibirá400igual 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
ForwardedyX-Forwarded-*se ignoran por defecto. En la LTS (Angular 21),X-Forwarded-HostyX-Forwarded-Protose aceptan salvo que digas lo contrario. Si usas la LTS: escribenew 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
getPrerenderParamsse ejecuta al compilar, la API tiene que estar arrancada duranteng 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:
curles 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: 404en la entrada**. Se aplica a todas las rutas sin entrada propia, y también a las redirecciones. Conantiguoenapp.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
404en 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.
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.logdel constructor sale en dos consolas, y unMath.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.

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: escribeprovideClientHydration(withIncrementalHydration()); sin esa opción, los bloqueshydratese 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 {}
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.

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:
hydratesolo 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.@placeholderno 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 whensolo 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ñallistoy@defer (hydrate when listo()) { <app-valoraciones /> }despierta en ese momento; así ocurrió al probarlo.hydrate neverafecta a todo su subárbol. Ningúnhydratede 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@deferse 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 sutimer(600ms). Al pulsar en los comentarios se creóComentarios; al pasar el ratón,BloquePadre, y al bajar hasta el final,Relacionados.AvisoLegalno 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.tsy Angular los empaqueta en un único trozo de 32 KB, que se descargó entero al dispararse el primer bloque. SiAvisoLegalestuviera 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ñadewithEventReplay(), queng addya escribe por ti, owithIncrementalHydration(), 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
0sigue 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.
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
TransferStateaparece 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
idderesourceno existe en la LTS (Angular 21), donde da un errorTS2769('id' does not exist in type). Si usas la LTS: usahttpResource, que ya pasa por la caché deHttpClient, o guarda el valor conTransferState, 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 serServeroClient.
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. SiALMACENes unInjectionTokenque usalocalStorage, enapp.config.server.tsle 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
provideServerRenderingno admite opciones y las respuestas defetchen 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.tsse 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 el400deallowedHostssalen ahí. - Temporizadores largos. Un
setIntervalen un constructor también se ejecuta en el servidor. Crea esos temporizadores enafterNextRender.
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é.
Ejercicio 9 (propuesto): Conecta la cookie
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 |