Streaming & Suspense
Transmita conteúdo renderizado no servidor progressivamente usando limites do React Suspense e loading.tsx.
Busque em todas as páginas da documentação
Transmita conteúdo renderizado no servidor progressivamente usando limites do React Suspense e loading.tsx.
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Cartão de receita de referência rápida -- pronto para copiar e colar.
// app/dashboard/loading.tsx -- UI de carregamento instantânea para toda a rota
export default function Loading() {
return <div className="animate-pulse">Carregando painel...</div>;
}// app/dashboard/page.tsx -- streaming granular com Suspense
import { Suspense } from "react";
export default function DashboardPage() {
return (
<main>
<h1>Painel</h1>
<Suspense fallback={<p>Carregando estatísticas...</p>}>
<SlowStats />
</Suspense>
<Suspense fallback={<p>Carregando gráfico...</p>}>
<SlowChart />
</Suspense>
</main>
);
}
async function SlowStats() {
const stats = await fetchStats(); // Atraso de 2s
return <StatsGrid data={stats} />;
}Quando usar isso: Você tem uma página com fontes de dados lentas e deseja exibir o conteúdo progressivamente em vez de bloquear a página inteira na consulta mais 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">Painel</h1>
{/* Cada seção transmite independentemente */}
<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 uma 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 arquivo cria um limite Suspense automático em torno da 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">Painel</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>
);
}O que isso demonstra:
loading.tsx como um limite Suspense em nível de rota para feedback de navegação instantâneo<Suspense> granulares para que cada widget do painel transmita independentementeloading.tsx: O Next.js envolve automaticamente o componente de página em um limite <Suspense> usando loading.tsx como fallback. Isso fornece um estado de carregamento instantâneo durante a navegação.<Suspense> em qualquer granularidade. Cada limite resolve independentemente e substitui seu fallback pelo conteúdo real.<Link>), o React renderiza o fallback de loading.tsx imediatamente enquanto busca o payload do RSC para a nova rota.Transfer-Encoding: chunked para enviar HTML progressivamente. Isso requer um runtime que suporte streaming (Node.js, Edge).Streaming com uma promessa passada (padrão defer):
// Server Component passa uma promessa sem aguardar
export default async function Page() {
const analyticsPromise = fetchAnalytics(); // não aguardado
return (
<Suspense fallback={<p>Carregando análises...</p>}>
<Analytics dataPromise={analyticsPromise} />
</Suspense>
);
}
// Client Component consome a promessa com use()
"use client";
import { use } from "react";
export function Analytics({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise); // suspende até ser resolvido
return <Chart data={data} />;
}Streaming sequencial vs. paralelo:
// Sequencial -- cada um aguarda em ordem (cascata)
async function Sequential() {
const a = await fetchA(); // bloqueia
const b = await fetchB(); // espera por a
return <>{a}{b}</>;
}
// Paralelo -- limites Suspense separados
function Parallel() {
return (
<>
<Suspense fallback={<p>A...</p>}><AsyncA /></Suspense>
<Suspense fallback={<p>B...</p>}><AsyncB /></Suspense>
</>
);
}// loading.tsx deve ser um export padrão retornando ReactNode
export default function Loading(): React.ReactNode {
return <Skeleton />;
}
// O fallback do Suspense aceita ReactNode
<Suspense fallback={<div>Carregando...</div>}>
<AsyncComponent />
</Suspense>
// Inferência de tipo do hook use()
const data: Data = use(dataPromise); // infere de Promise<Data>Limite Suspense muito alto -- Envolver sua página inteira em um único limite Suspense significa que nada é exibido até que todos os dados sejam resolvidos. Correção: Use múltiplos limites Suspense granulares em torno de cada fonte de dados independente.
Limite Suspense muito baixo -- Envolver cada pequeno componente em Suspense cria excesso de indicadores de carregamento e ruído visual. Correção: Agrupe componentes relacionados sob um único limite para uma experiência de carregamento coesa.
Deslocamento de layout de esqueletos -- Se o seu esqueleto não corresponder às dimensões do conteúdo real, a página salta quando os dados chegam. Correção: Faça os esqueletos terem a mesma altura/largura do conteúdo resolvido usando dimensões fixas ou proporções de aspecto.
loading.tsx se aplica apenas a navegações -- Em uma atualização completa (carregamento completo da página), loading.tsx é renderizado como parte do HTML inicial, mas não cria um verdadeiro limite de streaming para o SSR inicial em todos os casos. Correção: Use limites <Suspense> explícitos dentro de sua página para um comportamento de streaming confiável.
Rotas estáticas não transmitem -- Se uma rota for totalmente estática (sem dados dinâmicos), ela é pré-renderizada no tempo de compilação e servida como um arquivo HTML completo. O streaming se aplica apenas a rotas dinâmicas. Correção: Este é o comportamento esperado; nenhuma correção necessária.
Limites de erro e Suspense -- Se um filho do Suspense lançar um erro, o erro sobe. Sem um error.tsx ou <ErrorBoundary>, a página inteira falha. Correção: Emparelhe limites Suspense com limites de erro no mesmo nível.
| Alternativa | Use Quando | Não Use Quando |
|---|---|---|
loading.tsx | Você quer um estado de carregamento simples em nível de rota | Você precisa de controle granular sobre quais partes transmitem |
<Suspense> Aninhado | Cada seção tem fontes de dados independentes | Todos os dados vêm de uma única consulta rápida |
| Busca do lado do cliente (SWR) | Você precisa de atualizações em tempo real após a carga inicial | O streaming do lado do servidor é suficiente |
| Geração estática | Os dados não mudam entre as implantações | Os dados são específicos do usuário ou atualizados com frequência |
| Renderização Parcial (PPR) | Você quer um shell estático com buracos dinâmicos | Sua página inteira é dinâmica |
De uma aplicação SaaS Next.js 15 / React 19 em produção (SystemsArchitect.io).
// Exemplo de produção: streaming SSE com buffer de markdown
// Arquivo: 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 { /* ignora linhas SSE malformadas */ }
}
}
}O que isso demonstra em produção:
response.body.getReader() retorna um ReadableStreamDefaultReader para processar dados à medida que chegamTextDecoder({ stream: true }) lida com caracteres UTF-8 multibyte que podem ser divididos entre blocoshasIncompleteMarkdown() impede a renderização de markdown parcial (marcadores de negrito não correspondentes, blocos de código não fechados), o que causaria o flash de formatação quebradaoptions.signal?.aborted verifica o sinal do AbortController, permitindo que o usuário cancele no meio do streamaccumulated constrói o conteúdo completo para salvamento final, enquanto setState aciona renderizações incrementais da UIJSON.parse em cada linha SSE pode falhar em dados malformados. O catch silencioso é o comportamento correto para streamingloading.tsx cria um limite Suspense automático em nível de rota em torno de toda a página<Suspense> explícitos dão controle granular sobre quais partes transmitem independentementeloading.tsx para um estado de carregamento rápido; use <Suspense> para streaming de granularidade finaerror.tsx ou <ErrorBoundary>, a página inteira falha// Server Component: NÃO aguarde a promessa
export default async function Page() {
const dataPromise = fetchAnalytics();
return (
<Suspense fallback={<p>Carregando...</p>}>
<AnalyticsClient dataPromise={dataPromise} />
</Suspense>
);
}
// Client Component: consuma com 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 progressivamenteconst data: Data = use(dataPromise);
// TypeScript infere Data de Promise<Data> automaticamenteuse() desempacota o tipo da promessa, então use(Promise<T>) retorna Tloading.tsx é renderizado como parte do HTML inicial<Suspense> explícitos dentro de sua página para streaming confiável na carga inicial**) ou blocos de código não fechadosRevisado por Chris St. John·Última atualização: 19 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥