EspañolCatalàEnglish
Apariencia
Hablemos

BLOG / Arquitectura

Angular 20 y SSR: cuando una migración puede convertir tu aplicación en CSR sin que lo notes

Cuidado al actualizar Angular, tu aplicación SSR puede convertirse silenciosamente un una aplicación CSR.

Actualizamos varias aplicaciones Angular a la versión 20. La compilación terminaba correctamente, los tests seguían pasando y, al abrirlas en el navegador, todo parecía funcionar como antes.

Sin embargo, había un problema difícil de detectar: algunas aplicaciones que debían utilizar Server-Side Rendering estaban siendo entregadas mediante Client-Side Rendering.

No había una pantalla de error, ni un fallo evidente durante el despliegue. El usuario podía navegar normalmente porque Angular terminaba construyendo la página en el navegador.

Lo que había cambiado no era tanto el resultado visual como la forma en que la aplicación estaba siendo servida.

Y eso es precisamente lo que convierte este caso en algo interesante: una migración puede terminar aparentemente bien y, aun así, haber degradado silenciosamente una característica importante de la arquitectura.

El cambio que introdujo el problema

Conviene matizar que este comportamiento no apareció exactamente con Angular 20.0.

En febrero de 2026 Angular publicó una corrección para una vulnerabilidad SSRF que afectaba al servidor SSR. En la rama 20, la corrección llegó con @angular/ssr 20.3.17.

La vulnerabilidad estaba relacionada con la confianza depositada en cabeceras como Host y X-Forwarded-Host al construir URLs desde el servidor. Como solución, Angular reforzó la validación de los hosts que podían participar en el renderizado SSR.

A partir de ese cambio, determinadas configuraciones necesitan declarar explícitamente sus hosts mediante allowedHosts.

El problema surgía cuando una aplicación existente se actualizaba sin tener preparada esa configuración. En determinadas versiones y escenarios basados en CommonEngine, Angular podía rechazar el renderizado SSR y realizar un fallback a CSR.

La aplicación seguía respondiendo.

El navegador seguía mostrando la página.

Pero el servidor había dejado de renderizarla.

El downgrade puede quedar oculto

SSR Fallback to CSR

Por qué podemos no darnos cuenta

Cuando comprobamos una aplicación desde Chrome solemos validar el resultado final: vemos el contenido, navegamos entre páginas y verificamos que las llamadas a la API funcionan.

Pero esto no demuestra que SSR siga activo.

Con SSR, el servidor podría enviar algo parecido a:

<app-root>
  <main>
    <h1>Desarrollo de aplicaciones web</h1>
    <p>Diseñamos software adaptado a cada proyecto.</p>
  </main>
</app-root>

Con CSR podemos recibir inicialmente:

<app-root></app-root>
<script src="main.js"></script>

y obtener unos instantes después exactamente la misma página visual.

El panel Elements de DevTools tampoco resuelve necesariamente la duda, porque muestra el DOM después de que JavaScript haya podido modificarlo.

La comprobación correcta consiste en inspeccionar lo que realmente ha enviado el servidor.

Por ejemplo:

curl -s https://www.example.com/

o, durante el desarrollo:

curl -s http://localhost:4000/

También podemos utilizar View Page Source.

Si una ruta que debería llegar renderizada contiene únicamente un app-root vacío y el contenido aparece después de ejecutar JavaScript, debemos investigar qué ha ocurrido con SSR.

Esta comprobación parece trivial, pero es probablemente una de las más útiles que podemos realizar después de una migración.

allowedHosts y el entorno local

Una aplicación basada en CommonEngine puede declarar explícitamente qué hosts considera válidos:

const commonEngine = new CommonEngine({
  allowedHosts: [
    'localhost',
    '127.0.0.1',
    '::1',
    'example.com',
    'www.example.com'
  ]
});

Los tres primeros valores cubren los casos habituales de desarrollo local.

localhost es el nombre que utilizamos normalmente, 127.0.0.1 es el loopback de IPv4 y ::1 es su equivalente en IPv6.

Este último es fácil de olvidar porque normalmente escribimos:

http://localhost:4000

pero dependiendo del sistema operativo, Node y la configuración de red, localhost puede terminar resolviéndose mediante IPv6.

Una URL explícita de IPv6 tendría este aspecto:

http://[::1]:4000

Por eso, si queremos una configuración local portable, resulta razonable contemplar los tres.

En proyectos con varios entornos es preferible no incrustar todos los dominios directamente en el código. Podemos obtenerlos de configuración o variables de entorno y utilizar valores seguros para desarrollo.

Lo importante no es el mecanismo concreto, sino mantener una lista controlada.

Resolver el problema mediante:

allowedHosts: ['*']

puede resultar cómodo, pero elimina gran parte de la protección de seguridad que motivó precisamente este cambio.

ng serve no es una prueba suficiente

Aquí encontramos otra posible fuente de confusión.

Podemos ejecutar:

ng serve

abrir la aplicación en localhost:4200 y comprobar que todo funciona correctamente.

Eso no garantiza que el servidor SSR que desplegaremos en producción esté funcionando de la misma manera.

El servidor de desarrollo de Angular y el servidor Node generado para SSR no recorren exactamente el mismo camino. Para validar una migración conviene probar también el artefacto real:

ng build
node dist/my-app/server/server.mjs

y realizar una petición directamente contra él:

curl -s http://localhost:4000/

Lo que queremos comprobar es sencillo: el contenido que debería renderizar el servidor debe estar presente antes de ejecutar JavaScript.

Esta prueba tiene además otra ventaja: reproduce mucho mejor los problemas de configuración que podemos encontrar después en Docker, Kubernetes o detrás de un reverse proxy.

Cuando hay proxies por medio

En producción la petición rara vez llega directamente desde el navegador al proceso Angular.

Podemos tener una infraestructura como ésta:

El hostname que ve Angular puede no ser el que espera el desarrollador

hostname configuration

El dominio que utiliza el usuario puede ser:

www.example.com

pero el proceso Node podría recibir otro hostname, una dirección interna o unas cabeceras modificadas por alguna de las capas anteriores.

Por eso, cuando falla allowedHosts, no conviene añadir valores a ciegas. Primero debemos comprobar qué información está llegando realmente al servidor.

Lo mismo ocurre con Forwarded y X-Forwarded-*: sólo deberíamos confiar en estas cabeceras cuando tenemos delante un proxy controlado que las sobrescribe y valida correctamente.

El problema real no es únicamente Angular

La corrección inmediata consiste en configurar correctamente allowedHosts.

Pero hay una conclusión más interesante.

Nuestro proceso de validación probablemente era insuficiente.

Si una prueba posterior al despliegue sólo comprueba:

GET /
HTTP 200

una degradación de SSR a CSR puede pasar desapercibida.

Una aplicación SSR debería tener al menos alguna prueba que verifique su contrato de renderizado.

Por ejemplo:

Una comprobación SSR dentro del despliegue

Flujo de renderizado aplicación

Una comprobación básica podría incluso ser:

HTML=$(curl -fsS https://www.example.com/)

echo "$HTML" | grep "Desarrollo de aplicaciones"

En un proyecto real utilizaríamos probablemente una prueba algo más robusta, pero el principio es el mismo.

No estamos comprobando solamente que el servidor responda.

Estamos comprobando que continúa respetando una propiedad arquitectónica que consideramos importante.

¿Qué implica realmente perder SSR?

El problema no debe presentarse como si CSR fuese necesariamente incorrecto.

No lo es.

Muchas aplicaciones Angular funcionan perfectamente mediante Client-Side Rendering.

El riesgo aparece cuando hemos elegido SSR por motivos concretos y dejamos de utilizarlo sin saberlo.

Podemos perder contenido disponible desde la primera respuesta HTTP, cambiar el comportamiento inicial de renderizado, afectar a consumidores que no ejecutan JavaScript y modificar nuestras estrategias de hidratación, indexación o previews sociales.

Google es capaz de ejecutar JavaScript y una aplicación CSR no desaparece automáticamente de sus resultados. Por eso tampoco debemos simplificar la discusión a:

SSR bueno, CSR malo.

La cuestión es otra.

Si hemos diseñado y desplegado una aplicación como SSR, deberíamos poder demostrar que sigue siendo SSR.

Una migración debe validar algo más que la compilación

Este caso deja una lección bastante general.

Durante una actualización solemos verificar que la aplicación compila, que los tests pasan y que las principales funcionalidades continúan operativas.

Pero una migración también puede modificar características menos visibles:

  • renderizado en servidor;
  • hidratación;
  • prerenderizado;
  • caché;
  • cabeceras;
  • seguridad;
  • comportamiento detrás de proxies.

Por eso, después de actualizar una aplicación Angular con SSR, incorporaría tres comprobaciones muy sencillas:

  1. Ejecutar el servidor SSR real, no únicamente ng serve.
  2. Inspeccionar el HTML original devuelto por alguna ruta representativa.
  3. Automatizar al menos una prueba que falle si desaparece el contenido renderizado en servidor.

El problema de Angular 20 es interesante precisamente porque demuestra que una aplicación puede seguir funcionando mientras una característica importante ha dejado de hacerlo.

El síntoma puede ser invisible para el usuario.

Pero no debería ser invisible para nuestras pruebas.