Este es el último artículo del arco que empecé hace casi un año. Empecé hablando de arquitectura teórica de SSR en AWS, pasé por cada framework y patrón, y ahora cierro con un caso real completo. Un proyecto que migré de Angular SPA a Next.js SSR híbrido, con los números de antes y después, los problemas que no vi venir, y las decisiones que tomaría distinto hoy.
El proyecto es una plataforma SaaS B2B con dashboards, catálogo público de productos, blog, y área autenticada. Cambié nombres y ocultando detalles de negocio, pero los números son reales.
El punto de partida
La app original era Angular 15, SPA pura, desplegada en S3 + CloudFront. Un bundle de 1.8MB gzipped, con 340 componentes, Ngrx para estado, y un API Gateway + Lambda como backend. Funcionaba, pero tenía tres problemas serios.
Primero, el SEO era catastrófico. Las páginas públicas (catálogo, blog, landing) no rankeaban. Google Search Console mostraba que el bot cargaba el HTML vacío y se iba antes de que Angular hidratara. Pagábamos Ads para tráfico que deberíamos estar ganando orgánicamente.
Segundo, el LCP estaba en 4.2 segundos en 4G mobile según CloudWatch RUM. Core Web Vitals en rojo para el 60% de usuarios. Nuestro competidor directo cargaba en 1.3 segundos y nos comía almuerzo en rankings.
Tercero, el TTI era 5.8 segundos. Los dashboards autenticados mostraban skeleton loaders durante casi 6 segundos mientras bajaba el bundle, hidratación, y las requests iniciales al API. Los usuarios enterprise se quejaban.
flowchart TD
A[Usuario navega] --> B[CloudFront sirve index.html vacío]
B --> C[Navegador descarga bundle Angular 1.8MB]
C --> D[Parse y execute JS]
D --> E[Angular bootstrap]
E --> F[Router resuelve ruta]
F --> G[Componente hace fetch al API]
G --> H[Render con datos]
style B fill:#f99
style C fill:#f99
style G fill:#f99
Cada paso con color rojo es latencia en la ruta crítica antes de que el usuario vea algo útil.
La decisión: Next.js + App Router + Amplify Gen 2
Evalué tres caminos. Angular Universal con SSR, quedarme en Angular pero meter prerendering estático, o migrar a Next.js.
Angular Universal era el camino de menor fricción para el equipo (3 devs Angular senior), pero el soporte en AWS Amplify era inestable cuando empecé. Tuve que desplegar Angular 18 SSR manualmente varias veces y nunca logré que el streaming funcionara bien.
Prerendering estático servía solo para las páginas públicas, no para los dashboards autenticados. Dejaba el problema de TTI intacto.
Next.js con App Router resolvía todo, pero implicaba reescribir la app y retrainar al equipo. Mi argumento al CTO del cliente fue que el costo de la migración se pagaba con los leads orgánicos recuperados en 8 meses. Le creyeron y arrancamos.
flowchart LR
subgraph "Antes - SPA"
A1[Angular 15]
A2[Ngrx]
A3[340 componentes]
A4[S3 estático]
end
subgraph "Después - SSR híbrido"
B1[Next.js 15]
B2[Server Components]
B3[Zustand + SWR]
B4[Amplify Gen 2 Lambda]
end
A1 --> B1
A2 --> B3
A3 --> B2
A4 --> B4
La estrategia de migración
Migrar 340 componentes de un golpe era suicida. Dividimos en fases.
Fase 1 (semanas 1-4): Páginas públicas. Catálogo, blog, landing pages, registro y login. El 80% del tráfico orgánico. Las reescribí en Next.js con Server Components casi 100%, solo interactividad mínima con Client Components. SEO recuperado casi inmediatamente.
Fase 2 (semanas 5-10): Shell autenticado. Login flow, navegación, layout del dashboard, gestión de cuenta y settings. Todavía con rutas Angular activas vía iframe para el dashboard principal, pero el shell completo era Next.js.
Fase 3 (semanas 11-18): Dashboards. El core de la app. Reescribimos 5 dashboards principales de Angular a Next.js, manteniendo Angular corriendo para los dashboards secundarios menos críticos.
Fase 4 (semanas 19-24): Cleanup. Migración de los dashboards restantes o decisión de retirarlos si tenían poco uso. Retiro del código Angular.
Para servir ambas apps en paralelo durante la migración, usé CloudFront con path-based routing:
// amplify/backend.ts
import { defineBackend } from "@aws-amplify/backend";
import { Distribution, CachePolicy } from "aws-cdk-lib/aws-cloudfront";
import { HttpOrigin, S3Origin } from "aws-cdk-lib/aws-cloudfront-origins";
import { Bucket } from "aws-cdk-lib/aws-s3";
const backend = defineBackend({});
const angularBucket = Bucket.fromBucketName(
backend.createStack("LegacyStack"),
"LegacyAngularBucket",
"legacy-angular-app"
);
const distribution = new Distribution(
backend.createStack("CloudFrontStack"),
"Distribution",
{
defaultBehavior: {
origin: new HttpOrigin(process.env.NEXTJS_LAMBDA_URL!),
cachePolicy: CachePolicy.CACHING_DISABLED,
},
additionalBehaviors: {
"/legacy/*": {
origin: new S3Origin(angularBucket),
cachePolicy: CachePolicy.CACHING_OPTIMIZED,
},
"/legacy-api/*": {
origin: new HttpOrigin("old-api.internal.company.com"),
cachePolicy: CachePolicy.CACHING_DISABLED,
},
},
}
);
Todas las rutas nuevas iban a Next.js. Las rutas /legacy/* iban al Angular viejo. Durante la transición, los enlaces internos desde Next.js apuntaban a /legacy/dashboard/reports para mantener funcionalidad sin bloquear el user flow.
El reuso del API
No migré el backend. El API Gateway + Lambda Angular original siguió sirviendo ambas apps. Desde Next.js lo consumía como fetch desde Server Components:
// lib/api.ts
import { cache } from "react";
import { cookies } from "next/headers";
const API_URL = process.env.INTERNAL_API_URL!;
export const getProducts = cache(async (category?: string) => {
const cookieStore = await cookies();
const session = cookieStore.get("session")?.value;
const url = new URL(`${API_URL}/products`);
if (category) url.searchParams.set("category", category);
const res = await fetch(url, {
headers: session ? { Authorization: `Bearer ${session}` } : {},
next: { revalidate: 60, tags: ["products"] },
});
if (!res.ok) throw new Error("Failed to fetch products");
return res.json() as Promise<Product[]>;
});
El cache() de React deduplica calls dentro de la misma request. Si tres componentes piden productos, solo una request real al API sale. El next: { revalidate: 60 } cachea la respuesta por 60 segundos entre requests.
Este patrón me permitió migrar sin tocar el backend, que era el 70% del código del proyecto.
Server Components para el catálogo
El catálogo era la página más importante para SEO. Antes, Angular bajaba el bundle y hacía fetch al API. Ahora, Server Components renderizan el HTML completo en Lambda:
// app/catalog/[category]/page.tsx
import { getProducts, getCategory } from "@/lib/api";
import { notFound } from "next/navigation";
import { ProductGrid } from "./product-grid";
import { FilterSidebar } from "./filter-sidebar";
export async function generateMetadata({
params,
}: {
params: Promise<{ category: string }>;
}) {
const { category } = await params;
const cat = await getCategory(category);
if (!cat) return { title: "Categoría no encontrada" };
return {
title: `${cat.name} | MiTienda`,
description: cat.description,
openGraph: {
title: cat.name,
description: cat.description,
images: [cat.imageUrl],
},
};
}
export default async function CategoryPage({
params,
searchParams,
}: {
params: Promise<{ category: string }>;
searchParams: Promise<{ sort?: string; page?: string }>;
}) {
const { category } = await params;
const { sort = "popular", page = "1" } = await searchParams;
const [cat, products] = await Promise.all([
getCategory(category),
getProducts(category, { sort, page: parseInt(page) }),
]);
if (!cat) notFound();
return (
<div className="container mx-auto grid grid-cols-12 gap-6">
<aside className="col-span-3">
<FilterSidebar category={cat} />
</aside>
<main className="col-span-9">
<h1 className="text-3xl font-bold mb-6">{cat.name}</h1>
<ProductGrid products={products} />
</main>
</div>
);
}
ProductGrid es Server Component. Los filtros son Client Component porque tienen estado interactivo, pero el HTML inicial tiene todos los productos renderizados. Google bot ve una página completa.
El dashboard autenticado: streaming
Para los dashboards, usé streaming con Suspense. El shell del dashboard se renderiza instantáneo, los widgets pesados cargan progresivamente:
// app/dashboard/page.tsx
import { Suspense } from "react";
import { MetricsWidget } from "./metrics-widget";
import { RevenueChart } from "./revenue-chart";
import { RecentOrders } from "./recent-orders";
import { WidgetSkeleton } from "./skeletons";
export default function DashboardPage() {
return (
<div className="grid grid-cols-12 gap-4 p-6">
<section className="col-span-12">
<h1 className="text-2xl font-bold">Dashboard</h1>
</section>
<section className="col-span-4">
<Suspense fallback={<WidgetSkeleton />}>
<MetricsWidget />
</Suspense>
</section>
<section className="col-span-8">
<Suspense fallback={<WidgetSkeleton />}>
<RevenueChart />
</Suspense>
</section>
<section className="col-span-12">
<Suspense fallback={<WidgetSkeleton />}>
<RecentOrders />
</Suspense>
</section>
</div>
);
}
Cada widget hace su propio fetch async. El usuario ve el layout inmediatamente, y cada widget aparece cuando sus datos terminan de cargar. La sensación es mucho más rápida aunque el tiempo total sea similar.
Los números: antes vs después
Mediciones tomadas con CloudWatch RUM, sampling 100%, comparando el mes anterior a la migración contra el mes posterior a completar fase 3.
| Métrica | Antes (Angular SPA) | Después (Next.js SSR) | Cambio |
|---|---|---|---|
| LCP p75 mobile | 4.2s | 1.4s | -67% |
| FCP p75 mobile | 2.8s | 0.6s | -79% |
| TTI p75 mobile | 5.8s | 2.1s | -64% |
| Bundle size (homepage) | 1.8MB | 180KB | -90% |
| Bounce rate | 58% | 34% | -41% |
| Orgánico mensual | 12,400 sesiones | 38,700 sesiones | +212% |
| Conversión signup | 2.1% | 3.8% | +81% |
| CloudFront egress | 2.3 TB/mes | 0.9 TB/mes | -61% |
| Lambda costs | $180/mes | $340/mes | +88% |
| Total infra | $1,240/mes | $1,180/mes | -5% |
El costo Lambda subió porque ahora cada request hace trabajo server-side. Pero el ahorro en egress de CloudFront (bundle más pequeño, imágenes optimizadas) compensó. El total de infra bajó 5%.
El número que convenció a negocios fue el orgánico. Pasamos de 12K a 38K sesiones mensuales orgánicas en 6 meses post-migración, y el equipo de marketing pudo reducir gasto de Ads en 40% manteniendo leads constantes.
Lo que no vi venir
1. El cache de CloudFront quedó obsoleto agresivamente.
Next.js genera hashes nuevos en cada deploy para JS y CSS. Con deploys diarios, CloudFront invalidaba cache constantemente. Resolví con cache policy que respeta los headers Cache-Control: immutable de los assets hasheados. El HTML dinámico no se cachea, pero los assets sí por un año.
2. Server Components no pueden usar hooks de cliente, obvio, pero arrastró re-writes inesperados.
Cada librería que internamente usa React context se vuelve cliente. Mi librería de forms (react-hook-form) obligó a que todos los formularios sean client. Eso rompió mi plan inicial de tener forms server-rendered con progressive enhancement. Tuve que migrar forms a native HTML + Server Actions, lo que resultó siendo mejor.
3. El equipo batalló con el modelo mental.
Tres devs Angular llevaron 2 meses en estar cómodos con el modelo de Server Components vs Client Components. Los errores típicos: intentar usar useState en server component, pasar funciones como props cross-boundary, olvidar "use client" al tope. Hice workshops internos y documenté patrones comunes.
4. El monitoring cambió completamente.
En SPA, el performance era 100% browser-side. En SSR, la mitad del tiempo se gasta en Lambda. Agregué X-Ray con Datadog para trackear el pipeline completo: CloudFront -> Lambda -> API interno -> DB -> Lambda return -> CloudFront. Los primeros meses tuvimos bugs sutiles de latencia que solo se vieron con trazas distribuidas.
5. El fallback sin JS reveló bugs antiguos.
Probar Server Actions sin JavaScript descubrió que varios formularios viejos dependían en eventos onBlur para validación, que no se disparan en submit nativo. Refactoricé todo a validación server-side con zod.
6. Amplify Gen 2 todavía tiene bugs raros.
Hubo al menos tres deploys donde el build pasó pero la aplicación no arrancó en Lambda por issues con el runtime de Node. Tuve que fijar versiones en package.json y específicamente en engines. Desde entonces estable, pero me costó algunos incidentes.
7. La autenticación fue la parte más difícil.
Mover de auth client-side (token en localStorage) a server-side (cookies httpOnly) implicó reescribir todo el flow. Costos de sesión, refresh tokens, logout cross-device. Ver artículo 13 de este arco para detalles.
Lo que haría distinto
Si empezara hoy, optimizaría tres decisiones.
Primero, no mantendría CloudFront path-based routing tanto tiempo. Nos quedamos 4 meses con Angular legacy corriendo en /legacy/*, y eso creó ambigüedad constante. Los usuarios veían URLs distintas para partes de la app, cacheo se comportaba raro, y el equipo de soporte no sabía siempre qué versión estaba el usuario usando. Hoy, migraría dashboards secundarios a Next.js aunque sean más básicos, para tener todo en un solo stack más rápido.
Segundo, invertiría en visual regression testing desde el día uno. Pasamos dos semanas cazando bugs visuales que Playwright E2E no capturaba. Percy o Chromatic desde el principio nos habría ahorrado tiempo.
Tercero, usaría RSC para contenido editorial desde el principio. Al inicio prerenderaba el blog como static, pero los clientes editaban contenido en un CMS (Strapi) y necesitaban preview inmediato. RSC con revalidateTag desde un webhook del CMS resolvió eso elegantemente. Lo adopté tarde, debí ser día uno.
El arco cierra, el trabajo sigue
Este proyecto duró 6 meses en ejecución y 8 meses en ver los resultados completos de SEO. Desde que terminamos, el cliente ha crecido 3x en usuarios sin problemas de performance. La app hoy corre en Amplify Gen 2 con cero intervención manual, deploys automáticos desde main, y el equipo original es 100% autónomo con Next.js.
Empecé este arco hace casi un año con un artículo sobre arquitectura teórica de SSR. Pasé por Next.js, Angular, Astro, Nuxt, SvelteKit, Remix, micro-frontends, edge computing, CI/CD, auth, observability, multi-region, SEO, PWAs, i18n, security headers, y finalmente Server Actions. Cada tema salió de un proyecto real.
Lo que aprendí en este proceso es que SSR en AWS no es un framework ni una herramienta. Es una forma de pensar sobre dónde ocurre el trabajo: en el browser, en el edge, o en el Lambda. Con la arquitectura correcta, pagas poco en infra y entregas experiencia premium. Con la arquitectura incorrecta, tu Lambda bill se dispara y los usuarios igual esperan 5 segundos.
Si estás arrancando una migración así, lo más importante no es elegir el framework perfecto, sino medir lo que tienes hoy y definir qué quieres lograr. Los números mandan. Los frameworks son herramientas.
Gracias a todos los que leyeron estos 30 artículos durante el año. Voy a seguir escribiendo, probablemente sobre backend distribuido en AWS a partir del próximo mes. Nos leemos.
Top comments (0)