Email copiado — support@tuurt.com
Cargando experiencia
astro · 10 de octubre de 2026 · 6 min

Islas en Astro: cómo decidimos qué necesita JavaScript del cliente

Astro no hidrata nada por defecto, pero tampoco decide por nosotros la frontera de una isla. Vemos el caso donde hidratar la tarjeta completa en vez de un solo botón infló el bundle de un catálogo entero.

Por Equipo Tuurt

Islas en Astro: cómo decidimos qué necesita JavaScript del cliente

Astro parte de una premisa poco habitual entre los frameworks modernos: por defecto, una página no envía nada de JavaScript al navegador. Todo se renderiza a HTML estático en el servidor o en build time, y el JavaScript solo entra en escena cuando un componente lo pide explícitamente a través de una directiva client:*. A ese componente que sí se hidrata en el cliente, aislado del resto de la página, Astro lo llama isla.

La idea es sólida y resuelve un problema real: la mayoría de una página -cabecera, texto, imágenes, navegación- no necesita interactividad de ningún tipo, y enviar el runtime completo de React o Vue para renderizar contenido que podría ser HTML plano es trabajo desperdiciado. El problema no es la idea, sino que Astro no decide por nosotros qué parte de un componente necesita esa hidratación. Esa decisión es nuestra, componente por componente, y tomarla mal cuesta bundle real.

Lo que Astro decide y lo que no

Astro sí resuelve algo importante solo: si un componente .astro no lleva ninguna directiva client:*, se renderiza a HTML en el servidor y punto, sin excepciones y sin configuración adicional. Eso cubre, en la mayoría de los proyectos que hemos migrado a Astro, la mitad o más de los componentes de una página: tarjetas de contenido, secciones de texto, pies de página, breadcrumbs.

Lo que Astro no resuelve es la granularidad de ese componente. Si escribimos un ProductCard.tsx que contiene la imagen del producto, el precio, la descripción y un botón de "agregar al carrito", y le ponemos client:load porque el botón necesita manejar un click, Astro hidrata el componente completo: imagen, precio y descripción incluidos, aunque esas tres partes jamás vayan a cambiar ni a reaccionar a nada en el cliente. La unidad de hidratación es el componente que recibe la directiva, no la parte interactiva dentro de él. Esa confusión -tratar la isla como "la sección de la página que tiene algo interactivo" en vez de "el componente mínimo que necesita JavaScript"- es la causa más común de bundles inflados que vemos en revisiones de proyectos Astro.

Las directivas no son intercambiables

---
import ProductCard from '../components/ProductCard.tsx';
import AddToCartButton from '../components/AddToCartButton.tsx';
---
<ProductCard product={product} client:load />

Antes de llegar a la pregunta de qué hidratar, está la de cuándo. Astro ofrece varias directivas y cada una resuelve un problema distinto:

  • client:load hidrata en cuanto la página termina de cargar. Es la correcta para algo visible de inmediato y crítico para la primera interacción, pero es también la que más se usa por default sin pensarlo, cuando casi nunca es la que menos JavaScript bloqueante necesita.
  • client:idle espera a que el hilo principal quede libre (usa requestIdleCallback con un fallback basado en setTimeout donde no está disponible). Tiene sentido para interactividad que no es necesaria en el primer segundo: un acordeón, un formulario de newsletter en el footer.
  • client:visible espera a que el componente entre en el viewport, usando IntersectionObserver. Es la que mejor rendimiento da para cualquier cosa que esté debajo del fold, porque el JavaScript ni siquiera se descarga hasta que hace falta.
  • client:media hidrata solo si una media query coincide, útil para interactividad que existe únicamente en mobile o únicamente en desktop (un menú hamburguesa, por ejemplo).
  • client:only se salta el renderizado en servidor por completo. Es necesaria para componentes que dependen de APIs del navegador que no existen en el entorno de build, pero tiene un costo claro: no hay HTML inicial, así que el usuario ve un hueco en blanco hasta que el JavaScript llega y renderiza.

Usar client:load en todo por costumbre anula buena parte del beneficio de la arquitectura de islas: seguimos bloqueando el hilo principal con hidratación temprana para cosas que podrían esperar.

El caso: hidratar la tarjeta completa en vez del botón

En un catálogo con alrededor de cuarenta tarjetas de producto por página, cada ProductCard llevaba client:load porque el botón de "agregar al carrito" necesitaba un onClick. El resultado era previsible una vez que lo medimos: cuarenta instancias del componente completo hidratándose en el cliente, cada una incluyendo lógica de formateo de precio, lógica de selección de imagen según disponibilidad de stock, y el manejo de variantes de producto -todo código que no reaccionaba a ningún evento del usuario, pero que viajaba igual como parte del bundle de la isla y se ejecutaba igual en el cliente al montar el componente.

La solución no fue cambiar la directiva, fue cambiar la frontera del componente. Extrajimos el botón a un componente propio:

// AddToCartButton.tsx
export default function AddToCartButton({ productId }: { productId: string }) {
  const [loading, setLoading] = useState(false);

  async function handleClick() {
    setLoading(true);
    await addToCart(productId);
    setLoading(false);
  }

  return (
    <button onClick={handleClick} disabled={loading}>
      {loading ? 'Agregando...' : 'Agregar al carrito'}
    </button>
  );
}

Y dejamos la tarjeta como un componente .astro estático que renderiza ese botón como su única isla, con client:visible porque no hay ninguna razón para hidratar un botón que todavía no entró en pantalla:

---
import AddToCartButton from './AddToCartButton.tsx';
---
<article class="product-card">
  <img src={product.image} alt={product.name} />
  <h3>{product.name}</h3>
  <p>{formatPrice(product.price)}</p>
  <AddToCartButton productId={product.id} client:visible />
</article>

El HTML de la tarjeta -imagen, nombre, precio- queda como markup estático generado en build time, sin ningún runtime de framework asociado. Lo único que se hidrata es el botón, y solo cuando la tarjeta entra en el viewport. El formateo de precio, que antes vivía dentro del componente de React y se recalculaba en el cliente al montar, pasó a ser una función de utilidad que corre en el servidor al construir el HTML, porque no había ninguna razón para que ese cálculo ocurriera también en el navegador.

El costo que no se ve en el bundle: las props serializadas

Hay un segundo costo que suele pasar inadvertido incluso después de separar bien las islas: cada prop que le pasamos a un componente hidratado se serializa a JSON y se incrusta en el HTML de la página, para que el cliente pueda reconstruir el estado inicial del componente sin pedirlo de nuevo al servidor. Si la isla recibe el objeto product completo -con su array de imágenes en distintas resoluciones, su historial de variantes, sus metadatos de SEO- en vez de solo el productId que necesita, ese objeto entero viaja embebido en el HTML, aunque el componente hidratado solo use un campo. Pasar el identificador y dejar que el componente pida lo que falta, o pasar únicamente los campos que la parte interactiva usa, evita que el "ahorro" de hidratar menos componentes se pierda en props infladas.

Qué revisamos antes de marcar un componente como isla

Antes de ponerle una directiva client:* a un componente, separamos primero su parte interactiva de su parte puramente de presentación, aunque eso signifique crear un componente nuevo solo para eso; elegimos la directiva según cuándo necesita estar lista la interacción -client:visible por defecto para cualquier cosa debajo del fold, client:load reservado para lo que de verdad importa en el primer segundo-; revisamos qué props le pasamos a esa isla y recortamos a los campos que la parte interactiva usa, en vez de reenviar el objeto completo que ya tenía la página; y medimos con el inspector de red qué JavaScript se descarga por cada tipo de página antes de dar por cerrada la migración, porque la arquitectura de islas reduce el JavaScript enviado solo si la frontera de cada isla está bien puesta, no por el solo hecho de usar Astro.

astro frontend performance javascript
← Volver al blog