Saltar al contenido principal
Frontend

Content Layer API, Server Islands y View Transitions en Astro 5

10 min lectura
LD
Lucio Durán
Engineering Manager & AI Solutions Architect
También disponible en: English, Italiano

El Content Layer API: Pipeline de Datos Basado en Loaders

Las content collections de Astro 4 funcionaban adecuadamente para casos básicos. Los archivos Markdown se colocaban en src/content/, se definía un schema en config.ts, y Astro se encargaba del resto. La limitación era que "el resto" resultaba opaco. Las fuentes personalizadas requerían esfuerzo significativo. El contenido remoto dependía de soluciones provisionales. Las fuentes mixtas no estaban bien soportadas.

El content layer API de Astro 5 repiensa esto completamente. En vez de un sistema rígido basado en directorios, se define loaders — funciones que pueden traer contenido de cualquier lado:

// src/content.config.ts
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
import { createNotionLoader } from './loaders/notion';

const blog = defineCollection({
 loader: glob({ pattern: '**/*.md', base: './src/data/blog' }),
 schema: z.object({
 title: z.string(),
 date: z.coerce.date(),
 draft: z.boolean().default(false),
 tags: z.array(z.string()).default([]),
 }),
});

const changelog = defineCollection({
 loader: createNotionLoader({
 databaseId: process.env.NOTION_DB_ID,
 filter: { property: 'Status', select: { equals: 'Published' } },
 }),
 schema: z.object({
 title: z.string(),
 version: z.string(),
 body: z.string(),
 }),
});

export const collections = { blog, changelog };

El loader glob reemplaza el viejo escaneo implícito de archivos. Pero el poder real está en escribir loaders custom. Acá va un ejemplo de loader de Notion para un changelog:

// src/loaders/notion.ts
import { Client } from '@notionhq/client';

export function createNotionLoader(opts) {
 const notion = new Client({ auth: process.env.NOTION_TOKEN });

 return {
 name: 'notion-loader',
 async load({ store, logger }) {
 logger.info('Fetching from Notion...');
 const { results } = await notion.databases.query({
 database_id: opts.databaseId,
 filter: opts.filter,
 });

 store.clear();

 for (const page of results) {
 const blocks = await notion.blocks.children.list({
 block_id: page.id,
 });

 const body = blocksToMarkdown(blocks.results);
 const props = page.properties;

 store.set({
 id: page.id,
 data: {
 title: props.Name.title[0]?.plain_text ?? '',
 version: props.Version.rich_text[0]?.plain_text ?? '',
 body,
 },
 });
 }
 },
 };
}

El objeto store es la abstracción clave. Es un store de contenido persistente que Astro maneja entre builds. En modo dev, la función load() del loader se llama cuando hay cambios de archivos (para loaders basados en archivos) o al reiniciar el servidor (para loaders remotos). Durante el build, corre una vez y cachea. Esto significa que los builds incrementales realmente funcionan — si tu contenido de Notion no cambió, Astro puede saltear el fetch completamente usando el mecanismo de digest del store.

Al profilar esto en un sitio de documentación con 2.400 páginas que tira de archivos Markdown y un headless CMS, los tiempos de build bajan de 47 segundos (Astro 4, refetcheando todo) a 12 segundos con cache hit. No es un error de tipeo.

Server Islands: El Endgame de la Hidratación Parcial

Astro viene haciendo hidratación parcial desde la v1 con client:load, client:idle, client:visible. Eso era sobre cuándo hidratar un componente. Los server islands son sobre dónde correrlo.

El concepto: tu página es un shell estático renderizado en build time (o en el edge), con agujeros recortados para contenido dinámico que se llena con HTML renderizado en el servidor al momento del request. No se necesita JavaScript del lado del cliente para esas partes dinámicas.

---
// src/pages/product/[slug].astro
import ProductDetails from '../components/ProductDetails.astro';
import PricingPanel from '../components/PricingPanel.astro';
import ReviewSummary from '../components/ReviewSummary.astro';
---

<ProductDetails product={product} />

<!-- Esto se renderiza en el servidor al momento del request -->
<PricingPanel server:defer productId={product.id}>
 <div slot="fallback" class="skeleton-pricing">
 <div class="skeleton-line w-32 h-8"></div>
 <div class="skeleton-line w-24 h-6"></div>
 </div>
</PricingPanel>

<ReviewSummary server:defer productId={product.id}>
 <p slot="fallback">Cargando reviews...</p>
</ReviewSummary>

Cuando la página carga, el shell estático aparece instantáneamente desde la CDN. Después el browser hace requests de fetch paralelos para cada island con server:defer. El servidor renderiza el componente a HTML y lo manda de vuelta. El browser swapea el contenido fallback por el contenido real. Sin hidratación. Sin runtime de framework JavaScript. Solo reemplazo de HTML.

Consideremos un catálogo de e-commerce con 15.000 SKUs. Las páginas de detalle de producto son completamente estáticas (imágenes, descripciones, specs) mientras que pricing e inventario son server islands que pegan contra la base de datos en vivo. La arquitectura se ve así:

CDN (shell estático) ──► Browser ──► Edge Function (island de pricing)
 ├──► Edge Function (island de inventario)
 └──► Edge Function (island de reviews)

Los resultados fueron contundentes. TTFB bajó a 28ms para el shell estático (CDN hit). Los islands dinámicos resolvían en 80-120ms. LCP total estaba debajo de 200ms para el contenido estático, con el pricing apareciendo alrededor de 300ms. Antes, con full SSR en cada request, el TTFB andaba entre 400-600ms dependiendo de la carga de la base de datos.

Hay una sutileza que la documentación no enfatiza lo suficiente: los server islands pueden tener políticas de caché independientes. El island de pricing puede ser cache-busted en cada request mientras el resumen de reviews puede cachearse por 5 minutos:

// En el server endpoint de tu island
export const config = {
 cache: {
 pricing: 'no-store',
 reviews: 'public, max-age=300, stale-while-revalidate=60',
 },
};

Esta granularidad es algo que no es posible lograr fácilmente con full-page SSR o ISR.

View Transitions: Persistencia de Estado y Navegación Nativa

Las view transitions en Astro 5 están construidas sobre la API nativa de View Transitions (con fallback para Firefox/Safari), y cambian fundamentalmente cómo se sienten las MPAs. Un sitio estático navega como una SPA sin mandar un router del lado del cliente.

---
// src/layouts/Base.astro
import { ViewTransitions } from 'astro:transitions';
---
<html>
 <head>
 <ViewTransitions />
 </head>
 <body>
 <nav transition:persist="main-nav">
 <!-- La navegación persiste entre transiciones de página -->
 </nav>
 <main transition:animate="slide">
 <slot />
 </main>
 </body>
</html>

La directiva transition:persist es donde la cosa se pone genuinamente útil. Consideremos un componente de reproductor de música en un portfolio que necesita seguir tocando entre navegaciones. En una MPA tradicional, cada navegación mata el elemento de audio. Con transition:persist, el nodo DOM del componente sobrevive la transición:

<AudioPlayer
 transition:persist="audio-player"
 client:load
 currentTrack={track}
/>

El reproductor sigue tocando. El contexto de audio no se interrumpe. La animación del visualizador continúa. Simplemente funciona. Esto antes solo era posible con una arquitectura SPA, lo que significaba mandar un router, un runtime de framework, y manejar estado del lado del cliente.

Las mediciones de rendimiento de navegación en un sitio de documentación de 200 páginas muestran la diferencia claramente. Navegación MPA tradicional (carga completa de página): 180-350ms dependiendo del peso de la página. Con view transitions: 60-90ms de tiempo de navegación percibido, porque Astro prefetchea la siguiente página en hover y transforma el DOM.

Renderizado Híbrido: Selección de Modo por Ruta

Astro 5 permite mezclar modos de renderizado a nivel de ruta. Las páginas de marketing pueden ser completamente estáticas, el dashboard puede utilizar server rendering, y los endpoints de API pueden ejecutarse en el edge:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import vercel from '@astrojs/vercel';

export default defineConfig({
 output: 'static', // default: static
 adapter: vercel({
 imageService: true,
 }),
});

Después por página:

---
// src/pages/dashboard.astro
// Esta página opta por server rendering
export const prerender = false;

const session = await getSession(Astro.cookies);
if (!session) return Astro.redirect('/login');
---

Todo es estático por defecto. Optás rutas individuales a SSR con export const prerender = false. Esto es lo inverso del approach de Next.js donde se está en SSR-land por defecto y optás por generación estática.

Comparación de Rendimiento con Next.js

Los siguientes datos provienen de sitios de contenido comparables en producción:

| Métrica | Astro 5 (sitio docs, 800 páginas) | Next.js 15 (sitio docs, 650 páginas) | |---------|-----------------------------------|--------------------------------------| | Tiempo de build | 18s | 94s | | JS mandado (homepage) | 12KB | 87KB | | TTFB (CDN) | 22ms | 31ms | | LCP (mobile 3G) | 1.2s | 2.1s | | TTI (mobile 3G) | 1.4s | 3.8s |

La diferencia en tiempo de build es en gran parte porque Astro no necesita bundlear un runtime de React del lado del cliente para cada página. La diferencia de JS es la grande — 12KB vs 87KB importa enormemente en redes limitadas.

Pero esta comparación es injusta si tu sitio es muy interactivo. El sitio de Next.js que estoy comparando tenía búsqueda interactiva, filtrado del lado del cliente, y un widget de feedback en cada página. Astro puede hacer todo eso (con islands de React), pero una vez que teners 5+ componentes interactivos por página, la arquitectura de islands empieza a perder su ventaja porque se está mandando React de todas formas.

Una regla práctica: si más del 30% de la superficie de la página es interactiva, quedarse con Next.js. Debajo de ese umbral, la arquitectura de islands de Astro te da rendimiento mediblemente mejor.

Migración desde Next.js: Problemas Comunes

Migrar un blog de Next.js 14 (340 posts, MDX, componentes custom) a Astro 5 revela varias trampas comunes:

Imports de componentes MDX: Next.js auto-importa componentes vía mdx-components.tsx. Astro necesita imports explícitos en cada archivo MDX o un plugin de remark para inyectarlos. Un plugin de remark resuelve esto:

// remark-auto-import.mjs
export function remarkAutoImport() {
 return (tree) => {
 const imports = [
 "import CodeBlock from '../../components/CodeBlock.astro';",
 "import Callout from '../../components/Callout.astro';",
 ];

 tree.children.unshift({
 type: 'mdxjsEsm',
 value: imports.join('\n'),
 data: {
 estree: {
 type: 'Program',
 body: imports.map((imp) => ({
 type: 'ImportDeclaration',
 specifiers: [/* parseado del string de import */],
 source: { type: 'Literal', value: imp.match(/from '(.+)'/)[1] },
 })),
 sourceType: 'module',
 },
 },
 });
 };
}

Optimización de imágenes: El componente <Image> de Next.js no traduce 1:1. El <Image> de Astro desde astro:assets maneja bien imágenes locales pero las imágenes remotas necesitan configuración explícita de domains en astro.config.mjs. Sitios con 200+ posts con imágenes remotas necesitan el allowlist de domains poblado.

Feed RSS: Next.js no tiene RSS built-in. El paquete @astrojs/rss de Astro es excelente pero la migración del content layer significó que los scripts de generación de RSS necesitan reescritura para usar la nueva API de getCollection().

El tiempo de build pasó de 94 segundos a 14 segundos. El tamaño del bundle por página bajó de ~90KB a ~8KB (solo las páginas con componentes interactivos mandan JS). Los Core Web Vitals mejoraron en todos los frentes — la mediana de LCP bajó de 1.8s a 0.9s en mobile.

Lo Que Todavía Falta

Astro 5 no es perfecto. El HMR del dev server es más lento que el de Next.js para sitios grandes (500+ páginas). El ecosistema de componentes pre-armados es más chico. Si se necesita patrones pesados de middleware (auth, A/B testing, feature flags en cada request), el modelo de server islands es menos ergonómico que el middleware de Next.js.

La experiencia con TypeScript también está levemente atrás. La inferencia de tipos de Next.js para getStaticProps y server components es más ajustada. El soporte de TypeScript de Astro mejoró masivamente, pero ocasionalmente se va a encontrar tipos any en el content layer API que Next.js atraparía.

Aun así, para sitios de contenido — sitios genuinamente orientados al contenido donde el trabajo principal es renderizar texto, imágenes y datos estructurados — Astro 5 se destaca como la herramienta más fuerte en la categoría. El content layer API es el pipeline de datos que los sitios de contenido siempre necesitaron, los server islands resuelven el problema de contenido-dinámico-en-páginas-estáticas de forma elegante, y las view transitions hacen que todo se sienta como una SPA moderna sin el impuesto de JavaScript.

Construir una página con el nuevo content layer, medir los tiempos de build y verificar los scores de Lighthouse proporciona datos suficientes para una decisión informada.

astroview-transitionscontent-collectionsserver-islandshybrid-renderingnextjsfrontend-architecture

Herramientas mencionadas en este artículo

VercelProbá Vercel
NetlifyProbá Netlify
Divulgación: Algunos enlaces en este artículo son enlaces de afiliado. Si te registrás a través de ellos, puedo recibir una comisión sin costo adicional para vos. Solo recomiendo herramientas que uso y en las que confío personalmente.
Compartir
Seguime