Angular 17 y 18 trajeron cambios significativos al SSR: application builder, non-destructive hydration, control flow nuevo y SSR sin necesidad de módulos separados. Si vienes de Angular 15 o anterior, el setup actual es otro mundo. En este artículo cubro todo el flujo: configurar SSR, desplegarlo en AWS Amplify con multi-environment, y las trampas específicas que me encontré con Angular en serverless.
Por qué Angular SSR importa
flowchart LR
subgraph CSR[Client Side Rendering clasico]
C1[Request] --> C2[HTML vacio]
C2 --> C3[Cargar bundle JS]
C3 --> C4[Ejecutar Angular]
C4 --> C5[Render]
style C2 fill:#ff6b6b
end
subgraph SSR[SSR con Hydration]
S1[Request] --> S2[HTML completo]
S2 --> S3[Usuario ve contenido]
S2 --> S4[Cargar bundle JS]
S4 --> S5[Hidratacion no destructiva]
style S2 fill:#51cf66
end
Con Angular 18 tenemos:
- Application builder (esbuild-based): builds 40% más rápidos que Webpack.
- Non-destructive hydration: reusa el HTML del server, no lo tira.
- Server routing: routes configurables server-side.
- Transfer state mejorado: elimina doble fetch.
Creando el proyecto correcto
npx @angular/cli@18 new mi-app --ssr --standalone --style scss
cd mi-app
Las flags importantes:
-
--ssr: genera la config de SSR desde el inicio. -
--standalone: usa componentes standalone (el modo moderno). -
--style scss: SCSS nativo.
La estructura resultante:
src/
app/
app.component.ts
app.config.ts # Config cliente
app.config.server.ts # Config servidor
app.routes.ts
app.routes.server.ts # Rutas prerenderizadas
main.ts # Cliente
main.server.ts # Servidor
server.ts # Express handler
angular.json
Configuración de routing server-side
Angular 18 permite configurar qué rutas son prerenderizadas, SSR o cliente:
// src/app/app.routes.server.ts
import { RenderMode, ServerRoute } from '@angular/ssr';
export const serverRoutes: ServerRoute[] = [
{
path: '',
renderMode: RenderMode.Prerender,
},
{
path: 'blog',
renderMode: RenderMode.Prerender,
},
{
path: 'blog/:slug',
renderMode: RenderMode.Prerender,
async getPrerenderParams() {
const posts = await fetch('https://api.miempresa.com/posts').then(r => r.json());
return posts.map(post => ({ slug: post.slug }));
},
},
{
path: 'producto/:id',
renderMode: RenderMode.Server, // SSR en cada request
},
{
path: 'admin/**',
renderMode: RenderMode.Client, // SPA normal
},
{
path: '**',
renderMode: RenderMode.Server,
},
];
Esto es poderoso: puedes tener rutas 100% estáticas, otras server-side, y otras CSR, todo en la misma app.
Transfer state para evitar doble fetch
El problema clásico de SSR: fetch en server, render HTML, cliente hidrata, fetch de nuevo en cliente. Angular 18 lo soluciona automáticamente cuando usas provideHttpClient(withFetch()):
// src/app/app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withViewTransitions } from '@angular/router';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';
import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http';
import { routes } from './app.routes';
import { authInterceptor } from './interceptors/auth.interceptor';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes, withViewTransitions()),
provideClientHydration(withEventReplay()),
provideHttpClient(
withFetch(),
withInterceptors([authInterceptor])
),
],
};
withFetch() habilita transfer state automático para HttpClient. Los datos que fetcheas en el servidor se serializan en el HTML y el cliente los reusa.
withEventReplay() captura eventos del usuario durante la hidratación y los reproduce. Si el usuario hace click antes de que Angular hidrate, ese click se procesa cuando Angular esté listo.
Server.ts para Lambda
El server.ts que viene por default está pensado para Express corriendo en un servidor. Para Lambda necesitamos adaptarlo:
// server.ts
import { APP_BASE_HREF } from '@angular/common';
import { CommonEngine } from '@angular/ssr';
import express from 'express';
import { fileURLToPath } from 'node:url';
import { dirname, join, resolve } from 'node:path';
import bootstrap from './src/main.server';
import serverless from 'serverless-http';
export function app(): express.Express {
const server = express();
const serverDistFolder = dirname(fileURLToPath(import.meta.url));
const browserDistFolder = resolve(serverDistFolder, '../browser');
const indexHtml = join(serverDistFolder, 'index.server.html');
const commonEngine = new CommonEngine({
enablePerformanceProfiler: false,
});
server.set('view engine', 'html');
server.set('views', browserDistFolder);
// Assets estáticos (en producción CloudFront los sirve, esto es para dev)
server.get('*.*', express.static(browserDistFolder, {
maxAge: '1y',
immutable: true,
}));
// Todas las rutas dinámicas
server.get('*', (req, res, next) => {
const { protocol, originalUrl, baseUrl, headers } = req;
commonEngine
.render({
bootstrap,
documentFilePath: indexHtml,
url: `${protocol}://${headers.host}${originalUrl}`,
publicPath: browserDistFolder,
providers: [{ provide: APP_BASE_HREF, useValue: baseUrl }],
})
.then(html => res.send(html))
.catch(err => next(err));
});
return server;
}
// Export para Lambda
export const handler = serverless(app(), {
binary: ['image/*', 'font/*', 'application/pdf'],
});
// Si estamos en desarrollo, levantar el server
if (process.env['NODE_ENV'] !== 'production') {
const port = process.env['PORT'] || 4000;
app().listen(port, () => {
console.log(`Dev server on http://localhost:${port}`);
});
}
Configurar el build para Lambda
En angular.json, agrega un server config optimizado:
{
"projects": {
"mi-app": {
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "dist/mi-app",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"server": "src/main.server.ts",
"prerender": true,
"ssr": {
"entry": "server.ts"
},
"assets": ["src/favicon.ico", "src/assets"],
"styles": ["src/styles.scss"],
"optimization": true,
"sourceMap": {
"scripts": false,
"styles": false,
"hidden": true,
"vendor": false
},
"outputHashing": "all",
"subresourceIntegrity": true
}
}
}
}
}
}
Deploy con Amplify Console
Amplify Hosting soporta Angular SSR directamente. El amplify.yml:
version: 1
frontend:
phases:
preBuild:
commands:
- npm ci --cache .npm --prefer-offline
build:
commands:
- npm run build -- --configuration=$AWS_BRANCH
artifacts:
baseDirectory: dist/mi-app
files:
- '**/*'
cache:
paths:
- .npm/**/*
- node_modules/**/*
test:
phases:
preTest:
commands:
- npm ci
test:
commands:
- npm run test:ci
Y en angular.json configuraciones por ambiente:
"configurations": {
"main": {
"fileReplacements": [
{ "replace": "src/environments/environment.ts", "with": "src/environments/environment.prod.ts" }
],
"budgets": [
{ "type": "initial", "maximumWarning": "500kb", "maximumError": "1mb" },
{ "type": "anyComponentStyle", "maximumWarning": "4kb" }
]
},
"develop": {
"fileReplacements": [
{ "replace": "src/environments/environment.ts", "with": "src/environments/environment.staging.ts" }
]
}
}
Control flow nativo
Angular 17+ reemplaza los directivos estructurales con control flow del lenguaje:
// Antes (Angular 15)
@Component({
template: `
<div *ngIf="user$ | async as user; else loading">
<ul>
<li *ngFor="let item of items; trackBy: trackById">
{{ item.name }}
</li>
</ul>
</div>
<ng-template #loading>Loading...</ng-template>
`
})
// Ahora (Angular 17+)
@Component({
template: `
@if (user$ | async; as user) {
<ul>
@for (item of items; track item.id) {
<li>{{ item.name }}</li>
} @empty {
<li>No hay items</li>
}
</ul>
} @else {
<p>Loading...</p>
}
`
})
El performance es mejor porque el compilador genera código más eficiente, y la legibilidad sube porque es más parecido a JavaScript normal.
Deferrable views para lazy loading
Esta feature es poderosa y poco usada. Permite cargar componentes lazy desde el template:
@Component({
template: `
<article>
<h1>{{ post.title }}</h1>
<div [innerHTML]="post.content"></div>
@defer (on viewport) {
<app-comments [postId]="post.id" />
} @placeholder {
<div class="skeleton">Cargando comentarios...</div>
} @loading (minimum 100ms) {
<div class="spinner"></div>
} @error {
<p>Error cargando comentarios</p>
}
@defer (on idle) {
<app-related-posts [tags]="post.tags" />
}
@defer (on interaction) {
<app-share-buttons [url]="post.url" />
} @placeholder {
<button>Compartir</button>
}
</article>
`
})
export class PostComponent {
post = input.required<Post>();
}
Los triggers disponibles:
-
on viewport: cuando el elemento entra al viewport. -
on idle: cuando el navegador está idle. -
on interaction: click, keypress, etc. -
on hover: mouseover. -
on timer: después de X tiempo. -
on immediate: inmediatamente (pero lazy-loaded).
Cada bloque @defer genera un chunk separado que solo se carga cuando se dispara el trigger.
Signals en componentes
Angular 17+ introduce signals nativos. Simplifican el state management:
import { Component, signal, computed, effect, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-product-page',
template: `
<div>
@if (product(); as p) {
<h1>{{ p.name }}</h1>
<p>Stock: {{ stockStatus() }}</p>
<button [disabled]="!inStock()" (click)="addToCart()">
Agregar al carrito
</button>
}
</div>
`
})
export class ProductPageComponent {
private http = inject(HttpClient);
productId = signal<string>('');
private productResponse$ = computed(() =>
this.http.get<Product>(`/api/products/${this.productId()}`)
);
product = toSignal(this.productResponse$(), { initialValue: null });
inStock = computed(() => (this.product()?.inventory ?? 0) > 0);
stockStatus = computed(() => {
const inventory = this.product()?.inventory ?? 0;
if (inventory === 0) return 'Sin stock';
if (inventory < 10) return `Solo ${inventory} disponibles`;
return 'En stock';
});
constructor() {
effect(() => {
console.log('Producto cambiado:', this.product()?.name);
});
}
addToCart() {
// ...
}
}
Arquitectura completa
flowchart TB
User[Usuario] --> CF[CloudFront Amplify]
CF -->|/, /blog/*| Static[Páginas prerenderizadas<br/>S3]
CF -->|/producto/*| Lambda[Lambda SSR]
CF -->|/admin/*| SPA[SPA shell + chunks]
CF -->|/_next/static/*| Assets[Assets hasheados<br/>cache 1 año]
Lambda --> API[Backend APIs]
SPA --> API
style Lambda fill:#ff9900,color:#000
style CF fill:#146eb4,color:#fff
style Static fill:#569A31,color:#fff
Performance real en producción
Un proyecto real con 80+ rutas, métricas después de migrar a Angular 18:
| Métrica | Angular 15 SSR | Angular 18 SSR |
|---|---|---|
| Build time | 4m 20s | 1m 50s |
| Initial JS | 290 KB | 185 KB |
| FCP | 1.9s | 0.9s |
| LCP | 2.8s | 1.4s |
| TTI | 4.1s | 2.6s |
| Lighthouse score | 72 | 94 |
La diferencia más grande vino de non-destructive hydration y deferrable views bien aplicadas.
Trampas que me encontré
1. window y document rompen el SSR.
Cualquier referencia directa hace que el build falle. Usa isPlatformBrowser:
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID, inject } from '@angular/core';
@Component({})
export class MyComponent {
private platformId = inject(PLATFORM_ID);
ngOnInit() {
if (isPlatformBrowser(this.platformId)) {
// Código que usa window/document
}
}
}
2. Interceptors HTTP se ejecutan en ambos lados.
Un interceptor que agrega Authorization: Bearer X desde localStorage falla en servidor. Checa el platform antes.
3. Zone.js en Lambda.
Angular sigue dependiendo de Zone.js por defecto. Hay que incluirlo en el bundle del server o el SSR no funciona.
4. Prerender tarda con muchas páginas.
Si tienes 10,000 páginas prerenderizadas, el build puede tomar 30+ minutos. Considera SSG parcial + SSR para las de baja frecuencia.
5. CloudFront cache y SSR.
Si cacheaste una página SSR con datos de usuario, otros usuarios ven esos datos. Siempre que una página dependa de auth, fuerza no-cache.
Cierre
Angular 18 es una plataforma sólida para SSR en AWS. Es más verboso que Next.js para ciertas cosas, pero tiene herramientas poderosas como signals, control flow y deferrable views que compensan. Para equipos que ya conocen Angular, migrar a la versión actual es una de las mejores inversiones que pueden hacer.
En el próximo artículo vamos con micro-frontends: Module Federation desplegado en AWS con varios equipos independientes.
Top comments (0)