StackPractices
advanced Por Mathias Paulenko

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.

Temas: frontend

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

flowchart diagram: El navegador pide la página

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

FrameworkRuntimeEnfoque
AstroNodeDirectivas client:* sobre componentes de cualquier framework de UI
FreshDenoIslas en el directorio islands/, solo Preact
MarkoNodeEl framework original de hidratación parcial, anterior al nombre
Elder.jsNodeSSG centrado en Svelte con hydrate-client
îlesNodeCentrado en Vue, basado en Vite
QwikNodeResumability: 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:visible por defecto: la mayoría de los componentes interactivos están bajo el fold y no necesitan hidratación inmediata

  • Usa client:load solo para interacciones críticas above-the-fold: buscadores, menús de navegación

  • Usa client:idle para 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:only para componentes que dependen de APIs del navegador: mapas, gráficos que necesitan window o canvas

  • 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:load por defecto: la mayoría de los componentes no necesitan hidratación inmediata. Usa client:visible o client:idle en 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 Date como props. Todo lo que cruza hacia una isla pasa por JSON: un Date llega como string, un Map como objeto plano.

See Also

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.