Streaming y Suspense
Transmite contenido renderizado en servidor progresivamente usando límites de Suspense de React y loading.tsx.
Busca en todas las páginas de la documentación
Transmite contenido renderizado en servidor progresivamente usando límites de Suspense de React y loading.tsx.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Tarjeta de referencia rápida -- lista para copiar y pegar.
// app/dashboard/loading.tsx -- interfaz de carga instantánea para toda la ruta
export default function Loading() {
return <div className="animate-pulse">Cargando dashboard...</div>;
}// app/dashboard/page.tsx -- streaming granular con Suspense
import { Suspense } from "react";
export default function DashboardPage() {
return (
<main>
<h1>Dashboard</h1>
<Suspense fallback={<p>Cargando estadísticas...</p>}>
<SlowStats />
</Suspense>
<Suspense fallback={<p>Cargando gráfico...</p>}>
<SlowChart />
</Suspense>
</main>
);
}
async function SlowStats() {
const stats = await fetchStats(); // 2s delay
return <StatsGrid data={stats} />;
}Cuándo usarlo: Tienes una página con fuentes de datos lentas y deseas mostrar contenido progresivamente en lugar de bloquear toda la página en la consulta más lenta.
// app/dashboard/page.tsx
import { Suspense } from "react";
import { RecentOrders } from "./recent-orders";
import { RevenueChart } from "./revenue-chart";
import { TopProducts } from "./top-products";
export default function DashboardPage() {
return (
<main className="grid grid-cols-2 gap-6 p-6">
<h1 className="col-span-2 text-2xl font-bold">Dashboard</h1>
{/* Cada sección se transmite independientemente */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
<Suspense fallback={<ListSkeleton rows={5} />}>
<TopProducts />
</Suspense>
<div className="col-span-2">
<Suspense fallback={<TableSkeleton />}>
<RecentOrders />
</Suspense>
</div>
</main>
);
}
function ChartSkeleton() {
return <div className="h-64 bg-gray-100 rounded animate-pulse" />;
}
function ListSkeleton({ rows }: { rows: number }) {
return (
<div className="space-y-3">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="h-8 bg-gray-100 rounded animate-pulse" />
))}
</div>
);
}
function TableSkeleton() {
return <div className="h-48 bg-gray-100 rounded animate-pulse" />;
}// app/dashboard/revenue-chart.tsx (Server Component)
import { db } from "@/lib/db";
import { ChartClient } from "./chart-client";
export async function RevenueChart() {
// Simula una consulta lenta
const revenue = await db.order.aggregate({
_sum: { total: true },
where: { createdAt: { gte: new Date(Date.now() - 30 * 86400000) } },
});
const dailyData = await db.$queryRaw`
SELECT DATE(created_at) as date, SUM(total) as total
FROM orders
WHERE created_at > NOW() - INTERVAL '30 days'
GROUP BY DATE(created_at)
ORDER BY date
`;
return <ChartClient data={dailyData} total={revenue._sum.total ?? 0} />;
}// app/dashboard/loading.tsx
// Este archivo crea automáticamente un límite de Suspense alrededor de la página
export default function DashboardLoading() {
return (
<div className="grid grid-cols-2 gap-6 p-6">
<h1 className="col-span-2 text-2xl font-bold">Dashboard</h1>
<div className="h-64 bg-gray-100 rounded animate-pulse" />
<div className="h-64 bg-gray-100 rounded animate-pulse" />
<div className="col-span-2 h-48 bg-gray-100 rounded animate-pulse" />
</div>
);
}Lo que esto demuestra:
loading.tsx como un límite de Suspense a nivel de ruta para retroalimentación de navegación instantánea<Suspense> granulares para que cada widget del dashboard se transmita independientementeloading.tsx: Next.js envuelve automáticamente el componente de página en un límite de <Suspense> usando loading.tsx como fallback. Esto proporciona un estado de carga instantáneo durante la navegación.<Suspense> en cualquier granularidad. Cada límite se resuelve independientemente y reemplaza su fallback con el contenido real.<Link>), React renderiza el fallback de loading.tsx inmediatamente mientras obtiene la carga de RSC para la nueva ruta.Transfer-Encoding: chunked para enviar HTML progresivamente. Esto requiere un runtime que admita streaming (Node.js, Edge).Streaming con una promesa pasada (patrón defer):
// Server Component pasa una promesa sin esperar
export default async function Page() {
const analyticsPromise = fetchAnalytics(); // no esperada
return (
<Suspense fallback={<p>Cargando análisis...</p>}>
<Analytics dataPromise={analyticsPromise} />
</Suspense>
);
}
// Client Component consume la promesa con use()
"use client";
import { use } from "react";
export function Analytics({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise); // se suspende hasta que se resuelve
return <Chart data={data} />;
}Streaming secuencial vs paralelo:
// Secuencial -- cada uno espera en orden (cuello de botella)
async function Sequential() {
const a = await fetchA(); // bloquea
const b = await fetchB(); // espera a
return <>{a}{b}</>;
}
// Paralelo -- límites de Suspense separados
function Parallel() {
return (
<>
<Suspense fallback={<p>A...</p>}><AsyncA /></Suspense>
<Suspense fallback={<p>B...</p>}><AsyncB /></Suspense>
</>
);
}// loading.tsx debe ser una exportación por defecto que devuelve ReactNode
export default function Loading(): React.ReactNode {
return <Skeleton />;
}
// El fallback de Suspense acepta ReactNode
<Suspense fallback={<div>Cargando...</div>}>
<AsyncComponent />
</Suspense>
// Inferencia de tipo del hook use()
const data: Data = use(dataPromise); // infiere de Promise<Data>Límite de Suspense muy alto -- Envolver toda tu página en un único límite de Suspense significa que nada se muestra hasta que se resuelven todos los datos. Solución: Usa múltiples límites de Suspense granulares alrededor de cada fuente de datos independiente.
Límite de Suspense muy bajo -- Envolver cada componente pequeño en Suspense crea spinners de carga excesivos y ruido visual. Solución: Agrupa componentes relacionados bajo un único límite para una experiencia de carga cohesiva.
Cambio de diseño de esqueletos -- Si tu esqueleto no coincide con las dimensiones del contenido real, la página salta cuando llegan los datos. Solución: Haz que los esqueletos tengan la misma altura/ancho que el contenido resuelto usando dimensiones fijas o relaciones de aspecto.
loading.tsx se aplica solo a navegaciones -- En una actualización completa (carga de página completa), loading.tsx se renderiza como parte del HTML inicial, pero no crea un verdadero límite de streaming para el SSR inicial en todos los casos. Solución: Usa límites explícitos de <Suspense> dentro de tu página para un comportamiento de streaming confiable.
Las rutas estáticas no se transmiten -- Si una ruta es completamente estática (sin datos dinámicos), se prerenderiza en el tiempo de compilación y se sirve como un archivo HTML completo. El streaming solo se aplica a rutas dinámicas. Solución: Este es el comportamiento esperado; no se requiere corrección.
Límites de error y Suspense -- Si un componente hijo de Suspense lanza un error, el error se propaga. Sin error.tsx o <ErrorBoundary>, toda la página falla. Solución: Empareja límites de Suspense con límites de error al mismo nivel.
| Alternativa | Úsalo Cuando | No lo Uses Cuando |
|---|---|---|
loading.tsx | Deseas un estado de carga simple a nivel de ruta | Necesitas control granular sobre qué partes se transmiten |
<Suspense> Anidado | Cada sección tiene fuentes de datos independientes | Todos los datos provienen de una única consulta rápida |
| Obtención de datos del lado del cliente (SWR) | Necesitas actualizaciones en tiempo real después de la carga inicial | El streaming del lado del servidor es suficiente |
| Generación estática | Los datos no cambian entre implementaciones | Los datos son específicos del usuario o se actualizan frecuentemente |
| Partial Prerendering (PPR) | Deseas un shell estático con espacios dinámicos | Tu página completa es dinámica |
De una aplicación SaaS de Next.js 15 / React 19 en producción (SystemsArchitect.io).
// Ejemplo de producción: streaming SSE con almacenamiento en búfer de markdown
// Archivo: src/hooks/use-stream-content.ts
const reader = response.body?.getReader();
const decoder = new TextDecoder();
let buffer = '';
const hasIncompleteMarkdown = (text: string): boolean => {
const boldCount = (text.match(/\*\*/g) || []).length;
if (boldCount % 2 !== 0) return true;
const codeBlockCount = (text.match(/```/g) || []).length;
if (codeBlockCount % 2 !== 0) return true;
const openBrackets = (text.match(/\[/g) || []).length;
const closeBrackets = (text.match(/\]/g) || []).length;
if (openBrackets !== closeBrackets) return true;
return false;
};
while (true) {
if (options.signal?.aborted) {
setState({ isStreaming: false, streamedContent: accumulated, error: 'Stream aborted' });
return;
}
const { done, value } = await reader.read();
if (done) {
if (buffer) { accumulated += buffer; }
break;
}
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6));
if (data.chunk) {
buffer += data.chunk;
const { complete, remaining } = extractCompleteUnits(buffer);
if (complete) {
accumulated += complete;
buffer = remaining;
setState({ isStreaming: true, streamedContent: accumulated, error: null });
options.onChunk?.(complete);
}
}
} catch { /* omite líneas SSE mal formadas */ }
}
}
}Lo que esto demuestra en producción:
response.body.getReader() devuelve un ReadableStreamDefaultReader para procesar datos a medida que lleganTextDecoder({ stream: true }) maneja caracteres UTF-8 de múltiples bytes que pueden estar divididos entre fragmentoshasIncompleteMarkdown() previene renderizar markdown parcial (marcadores de negrita no coincidentes, bloques de código sin cerrar, enlaces incompletos) que destellaría formato rotooptions.signal?.aborted verifica la señal del AbortController permitiendo al usuario cancelar a mitad de la transmisiónaccumulated construye el contenido completo para guardarlo, mientras que setState activa renderizados de UI incrementalesJSON.parse en cada línea SSE podría fallar en datos mal formados. El catch silencioso es el comportamiento correcto para streamingloading.tsx crea un límite de Suspense automático a nivel de ruta alrededor de toda la página<Suspense> dan control granular sobre qué partes se transmiten independientementeloading.tsx para un estado de carga rápido; usa <Suspense> para streaming finoerror.tsx o <ErrorBoundary>, toda la página falla// Server Component: NO esperes la promesa
export default async function Page() {
const dataPromise = fetchAnalytics();
return (
<Suspense fallback={<p>Cargando...</p>}>
<AnalyticsClient dataPromise={dataPromise} />
</Suspense>
);
}
// Client Component: consume con use()
"use client";
import { use } from "react";
function AnalyticsClient({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise);
return <Chart data={data} />;
}export default function Loading(): React.ReactNode {
return <Skeleton />;
}React.ReactNodeTransfer-Encoding: chunked para enviar HTML progresivamenteconst data: Data = use(dataPromise);
// TypeScript infiere Data de Promise<Data> automáticamenteuse() desenvuelve el tipo de promesa, entonces use(Promise<T>) devuelve Tloading.tsx se renderiza como parte del HTML inicial<Suspense> dentro de tu página para streaming confiable en carga inicial**) o bloques de código sin cerrarRevisado por Chris St. John·Última actualización: 19 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥