DEV Community

Gustavo Ramirez
Gustavo Ramirez

Posted on

Angular 18 SSR con hydration en AWS configuracion production-ready con Amplify

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
  },
];
Enter fullscreen mode Exit fullscreen mode

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])
    ),
  ],
};
Enter fullscreen mode Exit fullscreen mode

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}`);
  });
}
Enter fullscreen mode Exit fullscreen mode

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
          }
        }
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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" }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

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>
    }
  `
})
Enter fullscreen mode Exit fullscreen mode

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>();
}
Enter fullscreen mode Exit fullscreen mode

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() {
    // ...
  }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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)