Angular rendering: CSR, SSR y SSG
Kevin Dávila
Cuando construyes una aplicación Angular, la forma en que el HTML llega al navegador importa tanto como lo que ese HTML contiene. Afecta el SEO, la velocidad percibida, el coste de infraestructura y la experiencia del usuario.
Hoy hablaremos sobre las tres estrategias disponibles — CSR, SSR y SSG — y cómo combinarlas en la misma app. Para que la comparación sea honesta, voy a usar el mismo ejemplo en cada caso: una página de artículos de blog con una lista de posts. (como este Blog, curiosamente)
El ejemplo base
Antes de entrar en las estrategias, el componente que vamos a renderizar de las tres formas es este:
// articles.component.ts
import { Component, inject, OnInit, signal } from '@angular/core';
import { ArticlesService } from './articles.service';
@Component({
selector: 'app-articles',
template: `
<h1>Artículos</h1>
@for (article of articles(); track article.id) {
<article>
<h2>{{ article.title }}</h2>
<p>{{ article.summary }}</p>
</article>
}
`
})
export class ArticlesComponent implements OnInit {
private readonly articlesService = inject(ArticlesService);
readonly articles = signal<Article[]>([]);
ngOnInit() {
this.articlesService.getAll().subscribe(data => this.articles.set(data));
}
}
El componente es el mismo en los tres casos. Lo que cambia es dónde y cuándo se ejecuta.
CSR — Client-Side Rendering
Es el comportamiento por defecto de Angular. No necesitas configurar nada.
El servidor devuelve un HTML casi vacío:
<!-- Lo que el navegador recibe inicialmente -->
<!doctype html>
<html>
<body>
<app-root></app-root>
<script src="main.js"></script>
</body>
</html>
El navegador descarga main.js, Angular arranca, y el componente se renderiza. Solo en ese momento el usuario ve el contenido.
# Crear un proyecto SPA (comportamiento por defecto)
ng new mi-blog
ng serve
Lo que ocurre con el ejemplo:
El navegador recibe el HTML vacío
Descarga y ejecuta main.js
Angular monta ArticlesComponent
ngOnInit dispara la llamada HTTP a la API
Llegan los datos, Angular renderiza los artículos
El usuario ve un momento de pantalla en blanco o un spinner mientras ocurren los pasos 2 al 5.
Cuándo tiene sentido: paneles de administración, dashboards, apps internas, herramientas que requieren autenticación previa. Para estas apps el SEO no importa y la primera carga puede ser lenta sin penalización real.
Cuándo no: páginas públicas que necesitan indexarse en buscadores o aparecer en previsualizaciones de redes sociales.
Source: Angular — Client-side rendering
SSR — Server-Side Rendering
Con SSR, Angular se ejecuta en el servidor antes de responder al navegador. El servidor devuelve HTML ya construido con el contenido:
<!-- Lo que el navegador recibe con SSR -->
<!doctype html>
<html>
<body>
<app-root>
<h1>Artículos</h1>
<article>
<h2>Cómo funciona NgRx SignalStore</h2>
<p>SignalStore es la nueva forma de gestionar estado...</p>
</article>
<article>
<h2>Angular 19: novedades principales</h2>
<p>La versión 19 trajo el renderizado híbrido...</p>
</article>
</app-root>
<script src="main.js"></script>
</body>
</html>
El usuario ve el contenido de inmediato, sin esperar a que JavaScript arranque.
# Añadir SSR a un proyecto existente
ng add @angular/ssr
# O crear un proyecto nuevo con SSR
ng new mi-blog --ssr
Esto genera dos archivos de configuración nuevos:
// app.config.server.ts
import { ApplicationConfig } from '@angular/core';
import { provideServerRendering } from '@angular/ssr';
export const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
]
};
// app.config.ts
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
withEventReplay()
),
]
};
Lo que ocurre con el ejemplo:
La petición llega al servidor Node.js
Angular ejecuta ArticlesComponent en el servidor
ngOnInit corre, llama a la API, obtiene los datos
El servidor genera el HTML con los artículos ya incluidos
El navegador recibe HTML completo y lo muestra de inmediato
JavaScript descarga en paralelo
Hidratación: Angular reutiliza el DOM existente y añade los event listeners
Ese último paso es la hidratación. Angular no borra y vuelve a renderizar — reconoce el DOM que ya existe y lo "activa".
withEventReplay() captura los clicks y eventos que el usuario pueda hacer durante esos milisegundos en que la hidratación aún no ha terminado, y los reproduce después. Sin esto, si alguien hace click en un botón antes de que Angular hidrate, ese click se pierde.
Cuándo tiene sentido: páginas de producto, artículos, feeds de noticias, resultados de búsqueda. Cualquier contenido público, dinámico y que necesite SEO.
Cuándo no: páginas muy personalizadas por usuario (cookies, sesión) que son difíciles de renderizar en servidor sin información del cliente. Para esas, CSR sigue siendo una opción válida.
Source: Server-side rendering • Angular
SSG — Static Site Generation (Prerendering)
SSG genera el HTML en tiempo de build, no en cada petición. Cuando el usuario visita la página, recibe un archivo HTML estático servido directamente desde un CDN o servidor de archivos. No hay Node.js procesando nada.
# Añadir SSR con prerendering
ng add @angular/ssr
# Construir con prerendering activado
ng build --prerender
O en angular.json:
{
"projects": {
"mi-blog": {
"architect": {
"build": {
"options": {
"prerender": true
}
}
}
}
}
}
Lo que ocurre con el ejemplo:
Al ejecutar ng build --prerender, Angular detecta todas las rutas
Para cada ruta, ejecuta el componente como haría SSR
Genera archivos .html estáticos con el contenido ya incluido
Esos archivos se suben a un CDN
dist/
browser/
index.html ← página principal prerenderizada
articles/
index.html ← lista de artículos prerenderizada
El usuario recibe el HTML en milisegundos desde el CDN. No hay servidor calculando nada.
El problema con rutas dinámicas
Si tienes una ruta /articles/:id, Angular necesita saber qué IDs prerenderizar. Para eso existe getPrerenderParams().
Primero necesitas crear el archivo de rutas del servidor:
// app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: 'articles/:id',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const res = await fetch('https://api.example.com/articles');
const articles = await res.json();
return articles.map((a: Article) => ({ id: String(a.id) }));
}
}
];
Angular ejecuta getPrerenderParams() durante el build, obtiene todos los IDs y genera un HTML estático por cada uno:
dist/
browser/
articles/
1/index.html
2/index.html
3/index.html
Cuándo tiene sentido: páginas de marketing, documentación, blogs con contenido que no cambia cada hora, catálogos de producto estables. Si el contenido cambia una vez al día como mucho, SSG funciona bien.
Cuándo no: contenido que cambia frecuentemente (precios en tiempo real, stock, feeds de redes sociales) o que depende del usuario.
Source: Build-time prerendering • Angular
Renderizado híbrido: las tres estrategias en la misma app
La realidad es que casi ninguna aplicación real encaja perfectamente en una sola estrategia. Un blog tiene una página de inicio pública (SSG), artículos individuales (SSG), pero también un panel de administración (CSR) y una sección de comentarios en tiempo real (SSR).
Angular 19 introdujo la API de renderizado por ruta en app.routes.server.ts. Con ella puedes asignar una estrategia diferente a cada ruta:
# Crear proyecto con soporte de rutas de servidor
ng new mi-blog --ssr --server-routing
# o añadirlo a uno existente
ng add @angular/ssr --server-routing
// app.routes.server.ts
import { RenderMode, ServerRoute, PrerenderFallback } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
// Página principal: prerenderizada en build time (SSG)
{
path: '',
renderMode: RenderMode.Prerender,
},
// Lista de artículos: prerenderizada
{
path: 'articles',
renderMode: RenderMode.Prerender,
},
// Artículo individual: prerenderizado con IDs dinámicos
{
path: 'articles/:id',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const res = await fetch('https://api.example.com/articles');
const articles = await res.json();
return articles.map((a: Article) => ({ id: String(a.id) }));
},
},
// Sección de comentarios: SSR (contenido dinámico en tiempo real)
{
path: 'articles/:id/comments',
renderMode: RenderMode.Server,
},
// Panel de administración: CSR (solo usuarios autenticados, sin SEO)
{
path: 'admin',
renderMode: RenderMode.Client,
},
// Perfil de usuario: SSR (contenido específico por usuario)
{
path: 'profile',
renderMode: RenderMode.Server,
},
// Cualquier ruta no definida: SSR como fallback
{
path: '**',
renderMode: RenderMode.Server,
},
];
Y registrar las rutas en la configuración del servidor:
// app.config.server.ts
import { ApplicationConfig } from '@angular/core';
import { provideServerRendering, provideServerRouting } from '@angular/ssr';
import { serverRoutes } from './app.routes.server';
export const serverConfig: ApplicationConfig = {
providers: [
provideServerRendering(),
provideServerRouting(serverRoutes),
]
};
El componente ArticlesComponent no cambia. La misma implementación se prerenderiza, se renderiza en servidor o se ejecuta en cliente dependiendo de la ruta desde la que se accede.
Source: Hybrid rendering with server routing • Angular
Hidratación incremental
Una vez que tienes SSR o SSG activo, puedes ir un paso más allá con la hidratación incremental. En lugar de hidratar toda la página de golpe cuando JavaScript carga, cada sección se hidrata de forma independiente según cuándo se necesita.
// app.config.ts
import { provideClientHydration, withIncrementalHydration } from '@angular/platform-browser';
export const appConfig: ApplicationConfig = {
providers: [
provideClientHydration(
withIncrementalHydration()
),
]
};
Con esto activado, puedes usar @defer con hydrate en lugar de on:
<!-- articles.component.html -->
<!-- El título hidrata de inmediato (parte crítica) -->
<h1>{{ title }}</h1>
<!-- La lista principal hidrata cuando entra en el viewport -->
@defer (hydrate on viewport) {
<app-articles-list [articles]="articles()" />
} @placeholder {
<app-articles-skeleton />
}
<!-- El widget de newsletter solo hidrata si el usuario hace hover -->
@defer (hydrate on hover) {
<app-newsletter-widget />
} @placeholder {
<div class="newsletter-placeholder">Suscríbete</div>
}
<!-- Los comentarios hidratan al hacer click en "Ver comentarios" -->
@defer (hydrate on interaction) {
<app-comments [articleId]="articleId()" />
} @placeholder {
<button>Ver comentarios</button>
}
El HTML completo llega prerenderizado o desde servidor. Solo el JavaScript necesario para cada sección se descarga y ejecuta cuando esa sección lo necesita.
Source: Incremental Hydration • Angular
Resumen visual
Petición del usuario
│
▼
¿Cómo llega el HTML?
│
├── CSR: HTML vacío → JS descarga → Angular renderiza en browser
│ Uso: dashboards, apps internas, herramientas autenticadas
│
├── SSR: Angular corre en servidor → HTML completo → browser hidrata
│ Uso: páginas públicas con contenido dinámico o por usuario
│
└── SSG: HTML generado en build → CDN sirve archivo estático
Uso: marketing, docs, blogs con contenido que no cambia a menudo
app.routes.server.ts (renderizado híbrido)
│
├── '/' → RenderMode.Prerender (SSG)
├── 'articles' → RenderMode.Prerender (SSG)
├── 'articles/:id' → RenderMode.Prerender (SSG + getPrerenderParams)
├── 'comments' → RenderMode.Server (SSR)
├── 'admin' → RenderMode.Client (CSR)
└── '**' → RenderMode.Server (SSR fallback)
Tabla comparativa
CSR | SSR | SSG | |
|---|---|---|---|
Primera carga visible | Lenta | Rápida | Muy rápida |
SEO | Malo | Bueno | Excelente |
Servidor necesario | No | Sí (Node.js) | No (CDN) |
Contenido dinámico | Sí | Sí | No |
Contenido por usuario | Sí | Sí | No |
Coste de infra | Bajo | Medio-alto | Muy bajo |
Complejidad | Baja | Media | Baja-media |
Una cosa a tener en cuenta
Con SSR hay código que no puedes ejecutar en el servidor: acceso a window, document, localStorage, o cualquier API exclusiva del browser.
Angular proporciona afterNextRender para esos casos:
// browser-only.component.ts
import { Component, afterNextRender } from '@angular/core';
@Component({ ... })
export class BrowserOnlyComponent {
constructor() {
afterNextRender(() => {
// Este bloque solo se ejecuta en el browser, nunca en servidor
const stored = localStorage.getItem('preferences');
});
}
}
Si tienes un componente de terceros que manipula el DOM directamente y rompe la hidratación, puedes marcarlo para que Angular no intente hidratarlo:
<app-legacy-chart ngSkipHydration />
Esto le dice a Angular que destruya y vuelva a renderizar ese componente en el cliente en lugar de intentar reutilizar el DOM del servidor.
Keywords
Angular SSR SSG CSR renderizado
Angular hybrid rendering
app.routes.server.ts Angular 19
Angular provideClientHydration
incremental hydration Angular