Frontend nextjs

Streaming con Suspense sin sacrificar tu ISR en Next.js

Un solo componente dinámico en tu layout puede volver dinámica toda tu página estática, si no lo aíslas bien. Así se evita ese efecto dominó con Suspense.

8 min
Next.js Suspense Streaming Performance

Tienes una home con ISR bien configurada, rápida, cacheada. Agregas un contador de carrito en el header — un dato que, por naturaleza, es distinto para cada usuario. Sin darte cuenta, toda tu home dejó de ser estática, porque ese header se renderiza en cada página del sitio, incluida la que tanto te costó dejar cacheada. Este artículo muestra cómo evitar exactamente ese efecto dominó.

Por qué un solo componente dinámico contamina toda la ruta

Next.js decide si una ruta puede ser estática analizando todo lo que se renderiza dentro de ella. Si en cualquier punto del árbol —incluido un componente de layout compartido, como el header— hay una llamada a algo inherentemente dinámico (leer cookies, headers(), searchParams), toda la ruta que incluye ese árbol pierde la posibilidad de ser servida desde el Full Route Cache.

// ❌ Sin aislar: esto vuelve dinámica CUALQUIER página que use este Header
export async function Header() {
  const cart = await getCart() // internamente lee cookies()
  const itemCount = cart?.items.length ?? 0

  return (
    <header>
      <span>Carrito ({itemCount})</span>
    </header>
  )
}

Si este Header vive en el layout raíz (como suele ser), y el layout raíz envuelve absolutamente todas las rutas, cada página del sitio hereda esta dependencia dinámica — incluida tu home con ISR, que ahora se re-renderiza en cada request en vez de servir HTML cacheado.

La solución: aislar la parte dinámica con <Suspense>

// ✅ src/components/molecules/CartCounter.tsx — su propio Server Component async
export async function CartCounter() {
  const cart = await getCart()
  const itemCount = cart?.items.reduce((sum, item) => sum + item.quantity, 0) ?? 0
  return <Badge badgeContent={itemCount}>🛒</Badge>
}
// ✅ Header.tsx — envuelve SOLO la parte dinámica en Suspense
import { Suspense } from 'react'

export function Header() {
  return (
    <header>
      <span>NextShop</span>
      <Suspense fallback={<CartCounterSkeleton />}>
        <CartCounter />
      </Suspense>
    </header>
  )
}

Este cambio, en apariencia pequeño, tiene un efecto grande: ahora Next.js puede seguir sirviendo el HTML estático del resto de la home desde el Full Route Cache, y solo la porción envuelta en <Suspense> se resuelve dinámicamente, en streaming, dentro de la misma respuesta HTTP.

Cómo funciona el streaming, en términos simples

Cuando el navegador pide la home, Next.js no espera a que todo esté listo antes de responder. Envía primero el HTML de todo lo que ya tiene resuelto (en este caso, prácticamente toda la página, porque viene de cache), con un placeholder (fallback) en el lugar exacto donde va el contenido dinámico. Cuando ese contenido dinámico termina de resolverse en el servidor, se inyecta directamente en su posición dentro de la misma conexión HTTP ya abierta — sin una segunda petición, sin recargar nada.

El detalle que importa: el fallback debe parecerse al contenido real

// El fallback NO es un spinner genérico — es una versión "vacía" del mismo elemento
export function CartCounterSkeleton() {
  return (
    <IconButton disabled>
      <ShoppingCartOutlinedIcon />
    </IconButton>
  )
}

Un fallback que ocupa un espacio distinto al contenido real produce layout shift cuando el contenido real aparece — el ícono del carrito “salta” de posición. Diseñar el fallback con las mismas dimensiones y forma que el contenido final evita ese salto visual, aunque el número dentro del badge tarde unos milisegundos más en aparecer.

Cómo verificar que realmente está funcionando

Con DevTools abierto, en la pestaña Network, aplica throttling (“Slow 3G” o similar) y recarga la home. Deberías notar que el resto de la página aparece de inmediato, y el contador del carrito aparece con un pequeño delay perceptible después — esa diferencia visual es la prueba directa de que el streaming está aislando correctamente lo dinámico de lo estático.

Cuándo usar loading.tsx en vez de <Suspense> granular

Next.js también ofrece loading.tsx, un archivo especial que envuelve automáticamente toda una ruta en un Suspense boundary. Es útil cuando la página completa depende de datos lentos y no tiene sentido mostrar nada parcial mientras carga. La diferencia con el patrón de este artículo:

SituaciónUsa
Toda la página depende de una consulta lenta, sin partes independientesloading.tsx (Suspense automático a nivel de ruta)
Solo una pieza específica (un widget, un contador) es dinámica, el resto puede servirse ya<Suspense> granular alrededor de esa pieza

Usar loading.tsx cuando en realidad solo una pieza pequeña es dinámica desperdicia la oportunidad de servir el resto de la página instantáneamente desde cache — es el equivalente a tirar por la ventana todo el trabajo de optimización de ISR que hiciste.

Conclusión

El streaming con Suspense no es solo “una forma más elegante de mostrar loading states” — es el mecanismo que evita que una sola pieza dinámica de tu UI arrastre a toda una ruta fuera del cache. La regla práctica: si algo en tu layout compartido necesita datos por-usuario, nunca lo dejes suelto en el árbol principal — aíslalo en su propio Server Component, envuelto en su propio <Suspense>.

¿Tu aplicación tiene componentes dinámicos “contaminando” páginas que deberían ser estáticas? Agenda una auditoría técnica gratuita de 15 minutos y lo revisamos juntos.

¿Tienes un proyecto en mente?

Convierte tu idea en un producto real

Desarrollo web, aplicaciones a medida y consultoría tecnológica para empresas y startups. Cuéntame tu proyecto y te respondo en menos de 24 horas.

Solicitar presupuesto Ver LinkedIn