Patrón Islands Architecture: Hidratación Parcial
Renderiza la página como HTML estático en el servidor e hidrata solo las islas interactivas. Cubre las directivas client: de Astro, hidratación parcial, estado compartido y cuándo las islas superan a una SPA.
Overview
Tu página probablemente envía JavaScript que nunca se usa. En una SPA tradicional o en una app Next.js con hidratación completa, el servidor envía HTML y luego el navegador descarga todo el árbol de componentes como JavaScript para reconstruir el mismo DOM solo para adjuntar manejadores de eventos. En una página de mucho contenido donde el 90% del marcado es texto, imágenes y enlaces, eso son muchos bytes gastados en re-renderizar lo que el usuario ya podía ver.
La arquitectura de islas invierte el comportamiento por defecto. La página se renderiza en el servidor como HTML estático, y solo los componentes que de verdad necesitan interactividad — las “islas” — se hidratan en el cliente. Todo lo demás se queda como marcado sin JavaScript. Una entrada de blog con un buscador envía JavaScript para el buscador, no para el artículo, la barra lateral, el pie de página y los huecos de anuncios.
Astro popularizó el patrón con sus directivas client (client:load, client:visible, client:idle), pero la idea es más antigua y más amplia. Jason Miller y Katie Sylor-Miller la documentaron en web.dev en 2020, Marko llevaba años haciendo hidratación parcial, y hoy Fresh (Deno), Elder.js, îles y la resumability de Qwik exploran la misma premisa: la mayoría de una página de contenido no necesita un runtime de JavaScript.
When to Use
- Sitios de mucho contenido donde la mayor parte de la página es estática: blogs, documentación, catálogos de e-commerce, páginas de marketing
- Páginas donde solo unos pocos componentes necesitan interactividad: un buscador, un contador de carrito, un formulario de newsletter
- Páginas críticas en rendimiento donde cada kilobyte de JavaScript te cuesta usuarios reales en dispositivos lentos
- Sitios orientados a SEO que necesitan un First Contentful Paint y un Time to Interactive rápidos
- Páginas multi-framework donde distintos widgets se construyeron en React, Svelte y Vue y una reescritura no va a ocurrir
- Páginas donde el SEO necesita ver contenido completamente renderizado pero algunas partes siguen necesitando comportamiento rico en el cliente
When NOT to Use
- Apps muy interactivas — dashboards, editores, chat — donde la mayoría de la página es interactiva de todos modos; habrás reinventado una SPA con pasos extra
- Single-page applications con routing complejo del lado del cliente y estado compartido en todas las vistas
- Páginas donde toda la superficie necesita hidratación inmediata (un editor de documentos colaborativo, por ejemplo)
- Proyectos sin SSR ni SSG: las islas necesitan HTML renderizado en el servidor para existir
- Equipos que no pueden tolerar dos modelos mentales; depurarás el render del servidor y la hidratación del cliente como preocupaciones separadas
Solution
Islas de Astro con directivas client
---
// page.astro — página de Astro con islas
import SearchBox from '../components/SearchBox.jsx';
import Comments from '../components/Comments.jsx';
import NewsletterForm from '../components/NewsletterForm.jsx';
import ProductGallery from '../components/ProductGallery.jsx';
const products = await fetchProducts();
const post = await fetchPost(Astro.params.slug);
---
<html>
<body>
<header>
<nav>
<!-- Navegación estática — cero JS -->
<a href="/">Home</a>
<a href="/products">Products</a>
<a href="/about">About</a>
</nav>
<!-- Isla: hidratar de inmediato (el buscador debe funcionar al instante) -->
<SearchBox client:load />
</header>
<main>
<h1>Our Products</h1>
<!-- Grid de productos estático — cero JS, HTML renderizado en el servidor -->
<div class="product-grid">
{products.map(product => (
<article class="product-card">
<img src={product.image} alt={product.name} loading="lazy" />
<h2>{product.name}</h2>
<p>{product.price}</p>
</article>
))}
</div>
<!-- Isla: hidratar al entrar en el viewport (la galería está bajo el fold) -->
<ProductGallery products={products} client:visible />
<!-- Isla: hidratar cuando el navegador esté libre (los comentarios no son críticos) -->
<Comments postId={post.id} client:idle />
</main>
<footer>
<!-- Isla: hidratar en idle (el formulario no necesita ser interactivo de inmediato) -->
<NewsletterForm client:idle />
</footer>
</body>
</html>
Directivas client explicadas
---
// client-directives.astro — todas las estrategias de hidratación
import InteractiveComponent from '../components/InteractiveComponent.jsx';
---
<!-- client:load — hidrata inmediatamente al cargar la página -->
<!-- Úsalo para: elementos interactivos above-the-fold que deben funcionar al instante -->
<SearchBox client:load />
<!-- client:visible — hidrata cuando el componente entra en el viewport -->
<!-- Úsalo para: componentes below-the-fold, galerías de imágenes, secciones de comentarios -->
<ProductGallery client:visible />
<!-- client:idle — hidrata cuando el navegador está inactivo (requestIdleCallback) -->
<!-- Úsalo para: elementos interactivos no críticos, formularios, widgets -->
<NewsletterForm client:idle />
<!-- client:media — hidrata cuando coincide una media query -->
<!-- Úsalo para: componentes responsive que solo necesitan JS en ciertas pantallas -->
<MobileMenu client:media="(max-width: 768px)" />
<!-- client:only — omite SSR, renderiza solo en el cliente -->
<!-- Úsalo para: componentes que dependen de APIs del navegador (window, document) -->
<Chart client:only="react" />
Isla de React en una página de Astro
// SearchBox.jsx — componente React usado como isla
import { useState, useEffect } from 'react';
export default function SearchBox() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
if (query.length < 2) {
setResults([]);
return;
}
const timer = setTimeout(async () => {
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
const data = await response.json();
setResults(data);
setIsOpen(true);
}, 200);
return () => clearTimeout(timer);
}, [query]);
return (
<div className="search-box">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
onFocus={() => setIsOpen(true)}
onBlur={() => setTimeout(() => setIsOpen(false), 200)}
/>
{isOpen && results.length > 0 && (
<ul className="search-results">
{results.map(result => (
<li key={result.id}>
<a href={result.url}>{result.title}</a>
</li>
))}
</ul>
)}
</div>
);
}
Pasar datos del servidor a las islas
---
// product-page.astro — pasa datos obtenidos en el servidor a una isla
import ProductConfigurator from '../components/ProductConfigurator.jsx';
const product = await fetchProduct(Astro.params.slug);
const variants = await fetchVariants(product.id);
const inventory = await fetchInventory(product.id);
---
<ProductConfigurator
client:visible
product={product}
variants={variants}
inventory={inventory}
/>
// ProductConfigurator.jsx — recibe props serializadas del servidor
import { useState } from 'react';
export default function ProductConfigurator({ product, variants, inventory }) {
const [selectedVariant, setSelectedVariant] = useState(variants[0]);
const [quantity, setQuantity] = useState(1);
const inStock = inventory[selectedVariant.id] >= quantity;
return (
<div className="configurator">
<h2>{product.name}</h2>
<div className="variants">
{variants.map(variant => (
<button
key={variant.id}
onClick={() => setSelectedVariant(variant)}
className={selectedVariant.id === variant.id ? 'selected' : ''}
>
{variant.name}
</button>
))}
</div>
<div className="quantity">
<button onClick={() => setQuantity(q => Math.max(1, q - 1))}>-</button>
<span>{quantity}</span>
<button onClick={() => setQuantity(q => q + 1)}>+</button>
</div>
<button disabled={!inStock}>
{inStock ? 'Add to Cart' : 'Out of Stock'}
</button>
</div>
);
}
Varias islas de frameworks distintos en una página
---
// multi-framework.astro — islas de React, Svelte y Vue en la misma página
import ReactWidget from '../components/ReactWidget.jsx';
import SvelteCounter from '../components/SvelteCounter.svelte';
import VueChart from '../components/VueChart.vue';
---
<div>
<h1>Multi-framework page</h1>
<!-- Isla de React -->
<ReactWidget client:visible />
<!-- Isla de Svelte -->
<SvelteCounter client:load />
<!-- Isla de Vue -->
<VueChart data={chartData} client:visible />
</div>
Isla con estado compartido
---
// cart-page.astro — islas que comparten estado mediante un store
import CartItems from '../components/CartItems.jsx';
import CartSummary from '../components/CartSummary.jsx';
import { cartStore } from '../stores/cart.ts';
---
<!-- Inicializa el store con datos del servidor -->
<script define:vars={{ initialCart }}>
window.__cartStore = initialCart;
</script>
<CartItems client:load store={cartStore} />
<CartSummary client:visible store={cartStore} />
// CartItems.jsx — isla de React que lee de un store compartido
import { useSyncExternalStore } from 'react';
export default function CartItems({ store }) {
const items = useSyncExternalStore(
store.subscribe,
() => store.getSnapshot(),
() => store.getServerSnapshot()
);
const removeItem = (id) => {
store.removeItem(id);
};
return (
<ul>
{items.map(item => (
<li key={item.id}>
{item.name} — ${item.price}
<button onClick={() => removeItem(item.id)}>Remove</button>
</li>
))}
</ul>
);
}
Comparación de rendimiento
// Impacto en rendimiento de islas vs. hidratación completa
// (cifras ilustrativas — mide tu propio bundle)
//
// Hidratación completa (SPA con Next.js):
// - HTML de la página: 50KB
// - Bundle de JavaScript: 350KB (React + código de la app + componentes)
// - Time to Interactive: 3.2s
// - FID: 120ms
//
// Arquitectura de islas (Astro):
// - HTML de la página: 55KB (algo más — incluye el HTML del componente renderizado)
// - Bundle de JavaScript: 45KB (solo las islas interactivas)
// - Time to Interactive: 1.1s
// - FID: 25ms
//
// Los 305KB de JavaScript ahorrados vienen de no enviar
// el runtime de React, el router y el código de componentes no interactivos.
How It Works
El ciclo de vida tiene tres fases. Primero, el servidor renderiza la página completa — incluidas las islas — a HTML, así que el navegador pinta contenido real en el primer paint sin pasada de render en el cliente. Segundo, el servidor serializa las props de cada isla dentro del marcado (Astro las incrusta como atributos en un elemento personalizado), junto con un pequeño script que sabe qué directiva respetar. Tercero, un runtime diminuto recorre la página e hidrata cada isla según su directiva: client:load de inmediato, client:visible mediante un IntersectionObserver, client:idle mediante requestIdleCallback.
Ese paso de serialización es la primera restricción con la que te topas. Las props que cruzan la frontera servidor→cliente deben ser serializables a JSON: nada de funciones, instancias de clases ni datos en streaming. Los Date llegan como strings; los Map como objetos. También significa que cada isla se renderiza dos veces en la práctica: una en el servidor para el HTML inicial y otra en el cliente cuando la hidratación conecta los manejadores de eventos y el estado. Los autores de frameworks lo llaman el coste del “doble render”, y es la razón por la que las islas encajan mal cuando casi toda la página es interactiva: pagarías el render del servidor y la hidratación completa.
El otro trade-off real es el estado compartido. Las islas son raíces aisladas: una isla de React y una de Svelte en la misma página no pueden importar el contexto de la otra. La respuesta de Astro son nanostores o un store propio serializado en window, que funciona para estado pequeño como un carrito pero se vuelve incómodo cuando la superficie compartida crece. Cuando te descubras pasando callbacks entre cinco islas, esa página quiere ser una SPA y el patrón te está peleando.
Vale la pena saber de dónde viene el término: el equipo de Marko tenía hidratación parcial funcionando años antes de que tuviera nombre, Jason Miller y Katie Sylor-Miller lo publicaron como “islands architecture” en web.dev en 2020, y desde entonces la idea se ha ramificado en resumability (Qwik serializa todo el estado de la app en el HTML, así que la hidratación ni siquiera re-ejecuta el código de los componentes) y en server components en streaming (React Server Components, que mueven la división estático/interactivo al árbol de componentes en lugar de a la página).
Una heurística práctica antes de adoptar el patrón: audita la página que estás construyendo y marca cada componente que realmente necesite un manejador de clicks, estado local o una suscripción. Si las marcas cubren menos de una cuarta parte de la página, las islas casi con seguridad enviarán menos JavaScript que la alternativa, y el tres cuartos restante se queda como HTML aburrido y cacheable que funciona incluso cuando el JavaScript no carga.
Variants
Islas con streaming de hidratación
---
// streaming-islands.astro — mejora progresiva con defer
import HeavyChart from '../components/HeavyChart.jsx';
import DataTable from '../components/DataTable.jsx';
---
<!-- El contenido estático se renderiza de inmediato -->
<section>
<h1>Dashboard</h1>
<p>Sales overview for Q4 2026</p>
</section>
<!-- La isla carga al hacerse visible — el gráfico está bajo el fold -->
<HeavyChart client:visible data={chartData} />
<!-- La isla carga en idle — la tabla es pesada pero no urgente -->
<DataTable client:idle rows={tableData} />
Islas anidadas
---
// nested-islands.astro — isla dentro de un layout estático
import Sidebar from '../components/Sidebar.jsx';
import FilterPanel from '../components/FilterPanel.jsx';
---
<!-- El sidebar es mayormente estático pero contiene un panel de filtros interactivo -->
<Sidebar>
<nav>
<a href="/category/all">All Products</a>
<a href="/category/electronics">Electronics</a>
</nav>
<!-- Solo esta parte se hidrata -->
<FilterPanel client:visible filters={availableFilters} />
</Sidebar>
Frameworks que implementan islas
| Framework | Runtime | Enfoque |
|---|---|---|
| Astro | Node | Directivas client:* sobre componentes de cualquier framework de UI |
| Fresh | Deno | Islas en el directorio islands/, solo Preact |
| Marko | Node | El framework original de hidratación parcial, anterior al nombre |
| Elder.js | Node | SSG centrado en Svelte con hydrate-client |
| îles | Node | Centrado en Vue, basado en Vite |
| Qwik | Node | Resumability: serializa el estado de la app, sin re-ejecución en el cliente |
Astro es el más flexible porque es agnóstico al framework: pagas un runtime por cada framework que realmente uses en la página. Fresh e îles son más pequeños pero te atan a Preact y Vue respectivamente. Qwik no son islas en sentido estricto (cada componente puede reanudarse de forma independiente), pero resuelve el mismo problema desde otro ángulo.
Best Practices
-
Para una guía más profunda, consulta Suspense Boundary: Declarative Loading States with React. Las islas combinan de forma natural con la Mejora Progresiva: el HTML estático debería funcionar incluso antes de que empiece la hidratación.
-
Usa
client:visiblepor defecto: la mayoría de los componentes interactivos están bajo el fold y no necesitan hidratación inmediata -
Usa
client:loadsolo para interacciones críticas above-the-fold: buscadores, menús de navegación -
Usa
client:idlepara widgets no críticos: formularios de newsletter, carruseles de recomendaciones -
Mantén las islas pequeñas: una isla de 5KB es mejor que una de 50KB. Extrae solo la parte interactiva
-
Pasa los datos del servidor como props: obtén los datos en el servidor y entrégaselos a las islas como props serializadas
-
Minimiza el estado compartido entre islas: cada isla debería ser autocontenida siempre que sea posible
-
Usa
client:onlypara componentes que dependen de APIs del navegador: mapas, gráficos que necesitanwindowocanvas -
Perfila el bundle de JavaScript: comprueba que las partes no interactivas no se estén hidratando por accidente
Common Mistakes
- Hidratarlo todo: envolver toda la página en una isla
client:load. Eso anula el propósito: has vuelto a la hidratación completa. - Usar
client:loadpor defecto: la mayoría de los componentes no necesitan hidratación inmediata. Usaclient:visibleoclient:idleen su lugar. - Islas grandes: una sola isla de 100KB con 20 sub-componentes. Divídela en islas más pequeñas para que solo se hidrate la visible.
- No pasar datos del servidor: pedir datos dentro de la isla en el cliente. Obtén los datos en el servidor y pásalos como props para evitar waterfalls.
- Compartir estado entre islas con variables globales: frágil y difícil de depurar. Usa una solución de gestión de estado adecuada si las islas necesitan comunicarse.
- Olvidar la frontera de serialización: pasar funciones, instancias de clases o
Datecomo props. Todo lo que cruza hacia una isla pasa por JSON: unDatellega como string, unMapcomo objeto plano.
See Also
- Documentación de islas de Astro: guía oficial de las directivas client y la hidratación parcial
- Arquitectura de islas en web.dev: el artículo de Jason Miller que popularizó el nombre del patrón
- Fresh (Deno): framework centrado en islas donde la interactividad es opt-in por directorio
- Mejora Progresiva: Funcionalidad por Capas sin JavaScript: las islas solo degradan con elegancia si el HTML estático funciona por sí solo
- Suspense Boundary: Declarative Loading States with React: el primo de la hidratación selectiva en server components
Código complementario: ejemplos de arquitectura de islas: versiones ejecutables de los snippets de esta página.
Preguntas frecuentes
¿Qué es la arquitectura de islas?
Un patrón donde la página se renderiza en el servidor como HTML estático y solo los componentes interactivos concretos (las "islas") se hidratan en el cliente. El resto de la página no envía JavaScript. Astro es el framework más popular que implementa este patrón.
¿En qué se diferencia del code-splitting?
El code-splitting divide el JavaScript en chunks que se cargan bajo demanda. La arquitectura de islas va más allá: las partes estáticas de la página no cargan JavaScript en absoluto. Solo las islas interactivas envían JS.
¿En qué se diferencia de la hidratación completa?
En una app con hidratación completa (una SPA típica con Next.js), el servidor envía HTML y el navegador descarga todo el árbol de componentes como JavaScript para reconstruir el mismo DOM y conectar los manejadores de eventos. Con islas, solo los componentes interactivos se hidratan: el artículo, la barra lateral y el pie de página no envían ni una línea de JS.
¿Qué directiva client debería usar?
Usa client:load para interacciones críticas above-the-fold, client:visible para componentes below-the-fold, client:idle para widgets no críticos y client:media para componentes solo en responsive.
¿Puedo usar varios frameworks en la misma página?
Sí. Astro admite React, Svelte, Vue, Solid y otros como islas en la misma página. Cada isla es independiente y solo se envía el runtime de su framework, así que tres frameworks significan tres runtimes, un coste que conviene sopesar.
¿La arquitectura de islas funciona con routing del lado del cliente?
No de forma natural. Las islas están pensadas para apps multi-página (MPA) donde cada página se renderiza en el servidor. Para routing del lado del cliente con hidratación parcial necesitarías una solución a medida o un framework como Fresh (Deno), que combina islas con navegación del lado del cliente.
Recursos Relacionados
Patrón Progressive Enhancement
Construye una base HTML funcional y añade capas de CSS y JavaScript encima. Cubre detección de características, degradación elegante y fallbacks sin JS.
PatternPatrón Suspense Boundary
Cómo usar React Suspense boundaries para declarative loading states. Cubre data fetching, streaming SSR, nested boundaries, y error boundaries.
GuideOptimización del Bundle JS: Guía Práctica Frontend
Guía práctica para medir y reducir el tamaño del bundle JavaScript con tree shaking, code splitting, reemplazo de dependencias, compresión y budgets en CI.
DocPlantilla de Performance Budget para Frontend
Plantilla para definir budgets de JS, CSS, imágenes y fuentes por ruta, con un método para elegir los números, enforcement en CI y umbrales de monitoreo en producción.
DocChecklist de Auditoria de Core Web Vitals
Checklist para auditar Core Web Vitals por pagina: LCP optimization steps, INP interaction tuning, CLS layout stability fixes, field data vs lab data analysis, CrUX integration y remediation tracking con ejemplos de codigo para imagenes, fuentes, JavaScript y CSS.