Durante años, que una aplicación Angular saliera bien en Google dependía de montar Angular Universal a mano, con su propio servidor Express, su AppServerModule y una buena dosis de paciencia. Ese modelo ha desaparecido. Desde Angular 17 el renderizado en servidor vive dentro del CLI en el paquete @angular/ssr, y las versiones posteriores han añadido algo más útil para el SEO que el SSR en sí: poder decidir ruta por ruta si una página se sirve renderizada en cada petición, generada en el build o directamente en el navegador. Este artículo repasa cómo queda todo en la documentación actual (Angular 22): configuración, modos de renderizado, hidratación, metadatos, datos estructurados y los errores con window y document que siguen rompiendo builds.
Por qué una SPA pura sigue siendo un problema de SEO
Googlebot ejecuta JavaScript, pero lo hace en una segunda fase y con recursos limitados. El resto de rastreadores (bots de redes sociales que generan las tarjetas al compartir, herramientas de previsualización, buena parte de los crawlers de IA) suelen leer solo el HTML que devuelve el servidor. Una SPA de cliente sirve un <app-root></app-root> vacío, y sin JavaScript no hay título, ni descripción, ni contenido que indexar. Con SSR o prerenderizado el HTML llega completo en la primera respuesta, y de paso mejora el tiempo hasta el primer contenido visible, que también cuenta para el posicionamiento.
Activar SSR: proyecto nuevo o existente
En un proyecto nuevo basta con la opción --ssr; en uno existente se añade el paquete con el schematic:
ng new mi-blog --ssr # o, en un proyecto que ya tienes ng add @angular/ssr
El comando genera o modifica cuatro piezas que conviene conocer:
server.ts: un servidor Node con Express que usaAngularNodeAppEnginepara convertir cada petición en una respuesta renderizada.app.config.server.ts: la configuración de la aplicación en el servidor, donde se registraprovideServerRendering.app.routes.server.ts: el arrayserverRoutesque dice cómo se renderiza cada ruta.main.server.ts: el punto de entrada del bundle de servidor.
La plantilla de server.ts es, simplificada, esta:
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();
app.use(express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false }));
app.use((req, res, next) => {
angularApp
.handle(req)
.then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))
.catch(next);
});
if (isMainModule(import.meta.url)) {
const port = process.env['PORT'] || 4000;
app.listen(port, () => console.log(`Servidor en http://localhost:${port}`));
}
export const reqHandler = createNodeRequestHandler(app);
La exportación reqHandler es la que reconocen las plataformas serverless y el propio servidor de desarrollo, así que no la renombres. Tras ng build, el cliente queda en dist/mi-blog/browser y el servidor en dist/mi-blog/server. La plantilla incluye un script serve:ssr:mi-blog en el package.json que lanza server.mjs con Node. Si el despliegue no es Node, existe el equivalente basado en Web API, AngularAppEngine con createRequestHandler en @angular/ssr, que trabaja con objetos Request y Response estándar.
Modos de renderizado por ruta
La pieza que más cambia el planteamiento es app.routes.server.ts. Cada ruta se asocia a uno de tres valores de RenderMode:
Modo | Cuándo se genera el HTML | Uso típico |
| En cada petición | Contenido que cambia o depende del usuario o de la cabecera |
| Una vez, durante el build | Páginas estables: artículos, documentación, landing pages |
| En el navegador | Zonas privadas, paneles, herramientas sin interés para buscadores |
Un blog razonable mezclaría los tres:
import { inject } from '@angular/core';
import { RenderMode, ServerRoute } from '@angular/ssr';
import { PostService } from './post.service';
export const serverRoutes: ServerRoute[] = [
{ path: '', renderMode: RenderMode.Prerender },
{ path: 'acerca-de', renderMode: RenderMode.Prerender },
{
path: 'post/:slug',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const posts = inject(PostService);
const slugs = await posts.getSlugs();
return slugs.map((slug) => ({ slug }));
},
},
{ path: 'cuenta/**', renderMode: RenderMode.Client },
{ path: '**', renderMode: RenderMode.Server },
];
Hay dos detalles en getPrerenderParams que dan problemas. El primero: se ejecuta siempre en tiempo de build, así que no puede depender de APIs propias del navegador ni de la petición. El segundo: inject solo funciona de forma síncrona, es decir, antes del primer await, por eso el servicio se inyecta en la primera línea. Si una ruta con parámetros no aparece en la lista devuelta, entra en juego el fallback de prerenderizado: PrerenderFallback.Server (el valor por defecto), Client o None.
Cada entrada admite además status y headers, útiles para devolver una página de error con código 404 o para añadir cabeceras de caché:
{
path: 'no-encontrado',
renderMode: RenderMode.Server,
status: 404,
headers: { 'Cache-Control': 'no-store' },
}
Si todo el sitio es estático, la opción outputMode: "static" en la configuración del builder de angular.json genera solo los HTML y no produce fichero de servidor, de modo que se puede publicar en un CDN o en un hosting estático. Con el valor por defecto se obtiene también el servidor. Prerenderizar muchas rutas alarga el build y engorda el despliegue, así que compensa reservarlo para lo que de verdad es estable.
Hidratación: evitar que el cliente rehaga el trabajo
Sin hidratación, el navegador recibe el HTML del servidor, arranca Angular y lo destruye para volver a pintarlo, con el parpadeo y el coste que eso supone. La hidratación reutiliza el DOM existente. Se activa con provideClientHydration en la configuración de la aplicación, y el schematic ya lo deja puesto:
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient, withFetch } from '@angular/common/http';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideHttpClient(withFetch()),
provideClientHydration(withEventReplay()),
],
};
withEventReplay() captura los eventos nativos (click, mouseover, focusin
) que el usuario dispara antes de que termine la hidratación y los reproduce después, de forma que un clic temprano en un botón no se pierde. Además, las peticiones HttpClient hechas en el servidor se guardan en una caché de transferencia que viaja con el HTML, así que el cliente no repite la misma llamada GET al arrancar. Se puede ajustar con withHttpTransferCacheOptions o desactivar con withNoHttpTransferCache.
Hidratación incremental con @defer
Según la documentación de la versión 22, la hidratación incremental viene activada por defecto al llamar a provideClientHydration(), y activa a su vez el replay de eventos; para quitarla hay que pasar withNoIncrementalHydration(). Requiere tener SSR e hidratación ya configurados. Su efecto es que los bloques @defer llegan del servidor ya renderizados pero «deshidratados»: se ven y se indexan, y el JavaScript asociado solo se descarga cuando se cumple un disparador hydrate:
<article>
<app-contenido-post [post]="post()" />
</article>
@defer (hydrate on viewport) {
<app-comentarios [postId]="post().id" />
} @placeholder {
<p>Cargando comentarios
</p>
}
@defer (on idle; hydrate on interaction) {
<app-relacionados />
} @placeholder {
<div>Artículos relacionados</div>
}
@defer (on viewport; hydrate never) {
<app-aviso-legal />
} @placeholder {
<div>Aviso legal</div>
}
Los disparadores disponibles son hydrate on idle (con tiempo máximo opcional, idle(500)), viewport, interaction (clic o tecla), hover (mouseover o focusin), immediate, timer(500ms), hydrate when condicion y hydrate never. Se escriben junto a los disparadores normales del @defer, separados por punto y coma, y la hidratación arranca con el primero que se cumpla. Dos matices: hydrate when solo se evalúa en el bloque deshidratado más externo, porque la condición vive en el componente padre; y hydrate never afecta solo al render inicial del servidor, de modo que si luego el bloque se renderiza en cliente (por ejemplo tras navegar) sus dependencias se cargan con el disparador normal. En bloques anidados, la hidratación va de fuera adentro hasta el bloque que dispara.
Conviene recordar que el contenido que importa para el SEO no debería ir dentro de un @defer sin hydrate: un @defer sin ningún disparador hydrate emite en el servidor solo su @placeholder, no el contenido diferido. La regla práctica es dejar el texto principal fuera y diferir lo accesorio, como comentarios, widgets o recomendaciones.
Título, metadatos y canonical
Para el título, lo más limpio es la propiedad title de la ruta. Acepta un texto fijo o una función ResolveFn para títulos dinámicos, y Angular lo actualiza al activarse la ruta:
import { Routes, ResolveFn } from '@angular/router';
import { inject } from '@angular/core';
import { PostService } from './post.service';
const tituloPost: ResolveFn<string> = async (route) => {
const post = await inject(PostService).getBySlug(route.paramMap.get('slug')!);
return post.titulo;
};
export const routes: Routes = [
{ path: '', component: Inicio, title: 'Inicio' },
{ path: 'post/:slug', component: PostPagina, title: tituloPost },
];
Si quieres un sufijo común («
| Mi Blog»), se extiende TitleStrategy y se registra con { provide: TitleStrategy, useClass: AppTitleStrategy }. Para el resto de etiquetas, el servicio Meta ofrece addTag, updateTag, getTag y removeTag, entre otros. El canonical no tiene servicio propio, y la documentación recomienda inyectar DOCUMENT en lugar de tocar el global document. Un servicio que reúna todo queda así:
import { DOCUMENT, inject, Injectable } from '@angular/core';
import { Meta, Title } from '@angular/platform-browser';
export interface DatosSeo {
titulo: string;
descripcion: string;
url: string;
imagen?: string;
}
@Injectable({ providedIn: 'root' })
export class SeoService {
private readonly title = inject(Title);
private readonly meta = inject(Meta);
private readonly doc = inject(DOCUMENT);
actualizar(d: DatosSeo): void {
this.title.setTitle(d.titulo);
this.meta.updateTag({ name: 'description', content: d.descripcion });
this.meta.updateTag({ property: 'og:title', content: d.titulo });
this.meta.updateTag({ property: 'og:description', content: d.descripcion });
this.meta.updateTag({ property: 'og:url', content: d.url });
if (d.imagen) {
this.meta.updateTag({ property: 'og:image', content: d.imagen });
}
this.canonical(d.url);
}
private canonical(url: string): void {
let link = this.doc.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!link) {
link = this.doc.createElement('link');
link.setAttribute('rel', 'canonical');
this.doc.head.appendChild(link);
}
link.setAttribute('href', url);
}
}
Se llama desde el componente de cada página, por ejemplo en el constructor o en un effect que reaccione al post cargado. Como updateTag busca por name o property y modifica la etiqueta existente, no se acumulan duplicados al navegar entre rutas. Esto funciona igual en servidor y en cliente, porque ambos trabajan sobre la abstracción de DOCUMENT; en el servidor, el DOM que se manipula es el que acaba serializado en el HTML de respuesta.
Datos estructurados JSON-LD
Los datos estructurados se pueden inyectar como una etiqueta <script type="application/ld+json"> con el mismo mecanismo. Hay que serializar con cuidado: si algún campo viene de una base de datos o de un usuario, un </script> dentro del texto cerraría el bloque y abriría la puerta a inyección de HTML, así que se escapa el carácter <:
import { DOCUMENT, inject, Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class JsonLdService {
private readonly doc = inject(DOCUMENT);
establecer(datos: Record<string, unknown>): void {
this.doc.querySelector('script[data-jsonld]')?.remove();
const script = this.doc.createElement('script');
script.type = 'application/ld+json';
script.setAttribute('data-jsonld', '');
script.textContent = JSON.stringify(datos).replace(/</g, '\u003c');
this.doc.head.appendChild(script);
}
}
// Uso en un componente de artículo
this.jsonLd.establecer({
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.titulo,
datePublished: post.fecha,
author: { '@type': 'Person', name: post.autor },
});
Después de construir, comprueba el resultado con una herramienta como la de resultados enriquecidos de Google; lo que importa es lo que ve el rastreador en el HTML, no lo que muestra el inspector del navegador tras ejecutar JavaScript.
Errores típicos: window, document y compañía
El servidor es Node, y allí window, document, navigator, location o localStorage no existen. El error ReferenceError: window is not defined durante el build o en la primera petición es el síntoma más habitual al portar una SPA a SSR. La documentación ofrece varias salidas, por orden de preferencia:
- Inyectar
DOCUMENTen vez de usar el global, como en los servicios anteriores. - Ejecutar el código solo en el navegador con
afterNextRenderoafterEveryRender, que el servidor omite. - Usar proveedores específicos de plataforma antes que comprobaciones
isPlatformBrowserrepartidas por el código.
import { afterNextRender, Component, signal } from '@angular/core';
@Component({
selector: 'app-tema',
template: `<button (click)="alternar()">Tema: {{ tema() }}</button>`,
})
export class TemaComponent {
tema = signal<'claro' | 'oscuro'>('claro');
constructor() {
afterNextRender(() => {
const guardado = localStorage.getItem('tema');
if (guardado === 'oscuro') this.tema.set('oscuro');
});
}
alternar() {
const nuevo = this.tema() === 'claro' ? 'oscuro' : 'claro';
this.tema.set(nuevo);
localStorage.setItem('tema', nuevo);
}
}
Aquí el servidor y el primer render del cliente producen el mismo HTML («claro»), y la preferencia guardada se aplica ya en el navegador sin provocar un desajuste de hidratación. Lo que sí está desaconsejado es usar isPlatformBrowser dentro de un @if de la plantilla para pintar contenido distinto en servidor y cliente: la propia documentación advierte de que provoca desajustes de hidratación y saltos de maquetación.
Otras causas frecuentes de errores de hidratación, también recogidas en la guía oficial:
- Manipular el DOM a mano (
innerHTML,appendChild,querySelectorpara mover nodos). Es el culpable más habitual. Se refactoriza hacia APIs de Angular y, si no hay más remedio, se marca el componente conngSkipHydration, que solo se puede poner en el nodo anfitrión y obliga a destruir y repintar ese componente y sus hijos. Aplicado al componente raíz desactiva la hidratación de toda la aplicación. - HTML inválido: una
<table>sin<tbody>, un<div>dentro de un<p>o un enlace dentro de otro enlace. El navegador corrige el árbol al parsearlo y deja de coincidir con lo que espera Angular. - Librerías de terceros que tocan el DOM, como gráficos de D3 o scripts de analítica y publicidad. Se inicializan dentro de
afterNextRender. - Inestabilidad de la aplicación: temporizadores, intervalos o promesas pendientes retrasan la hidratación y acaban en el error NG0506 tras diez segundos. En desarrollo,
provideStabilityDebuggingayuda a localizar al responsable.
Acceso a la petición y códigos de estado
Para el SEO también cuenta devolver el código HTTP correcto. Una página inexistente que responde 200 se llama «soft 404» y los buscadores la penalizan. Angular expone en @angular/core los tokens REQUEST, RESPONSE_INIT y REQUEST_CONTEXT, que valen null durante el build, en renderizado en cliente y en prerenderizado, por lo que hay que inyectarlos como opcionales:
import { inject, RESPONSE_INIT } from '@angular/core';
export class NoEncontradoComponent {
constructor() {
const respuesta = inject(RESPONSE_INIT, { optional: true });
if (respuesta) {
respuesta.status = 404;
}
}
}
Para casos fijos, el campo status de serverRoutes ya mostrado cubre lo mismo sin código. Las redirecciones se comportan distinto según el modo: con SSR salen como 301 o 302 reales, y en prerenderizado se convierten en redirecciones suaves con una etiqueta <meta http-equiv="refresh"> en el HTML estático.
Comprobar que funciona
La prueba más honesta es mirar lo que recibe un cliente sin JavaScript:
npm run build npm run serve:ssr:mi-blog curl -s http://localhost:4000/post/mi-primer-articulo | grep -E "<title>|description|canonical|ld+json"
Si el título, la descripción, el canonical y el JSON-LD aparecen en la salida de curl, los rastreadores los verán también. Es recomendable repetir la prueba con una ruta prerenderizada, una de modo servidor y una que no exista, para confirmar el 404. Para producción, completa el conjunto con un sitemap.xml que liste las URLs reales y un robots.txt que no bloquee los ficheros JavaScript ni CSS.
Lo que antes exigía un proyecto paralelo con su propia configuración es hoy una opción del CLI, y lo que queda por decidir es de criterio editorial: qué páginas merecen generarse una vez, cuáles deben salir frescas en cada petición y cuáles no necesitan existir para un buscador. Si vienes de empezar con el framework, el artículo sobre TypeScript con Angular: decoradores, DI tipado, HttpClient y señales cubre las piezas que se usan aquí (inyección con inject, servicios y señales).
Imagen: Pexels / Brett Sayles
