Next.js: Streaming & Suspense
Última atualização: 2026-08-26
A renderização preguiçosa elimina a necessidade de os usuários olharem para uma tela em branco enquanto esperam — a página é exibida à medida que é gerada, reduzindo o tempo até o primeiro byte (TTFB) em 60%.
1. O Que Você Vai Aprender
- Estratégia de Divisão de Suspense Boundary (Página → Tela de Skeleton → Conteúdo)
- Convenções do Arquivo
loading.tsxe Empacotamento Automático de Suspense - Design da Ordem de Carregamento em Streaming e Fallbacks de Suspense Aninhados
- Hook
use()do React 19: Consumindo Promises em Componentes - AI SDK
streamTextGera Texto com Efeito de Máquina de Escrever - Um modo híbrido combinando renderização em streaming e shells estáticos PPR
2. Uma História Real de um Arquiteto Front-End
(1) Ponto de Dor: A cascata de requisições torna o carregamento da página três vezes mais lento
Charlie é um arquiteto front-end na equipe TaskFlow. Ele percebeu que a página do Dashboard leva 4,2 segundos para carregar:
"A página contém 5 cartões de dados, 1 lista de atividades e 1 gráfico estatístico. Todos os dados são carregados sequencialmente com
awaitdentro dos componentes da página; uma única chamada de API lenta pode paralisar toda a página — deixando os usuários olhando para uma tela em branco, esperando ociosamente."
| Problema | Duração | Causa |
|---|---|---|
| Lista de usuários (200 ms) + Número de itens (300 ms) | 500 ms | Espera serial |
| Gráficos Estatísticos (800 ms) | 800 ms | Processamento lento no backend |
| Fluxo de Atividades (400 ms) | 400 ms | Consultas entre Serviços |
| TTFP Total | 1.700 ms | Tudo serial |
(2) A Solução com Streaming + Suspense
Divida a página em múltiplas boundaries de Suspense, com cada bloco de dados carregado independentemente via streaming.
export default function DashboardPage() {
return (
<div>
<h1>Visão Geral</h1>
<Suspense fallback={<SkeletonCards />}>
<UserCards />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<AnalyticsChart />
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<ActivityFeed />
</Suspense>
</div>
)
}
(3) Ganhos
| Dimensão | Antes da Otimização (Serial) | Depois da Otimização (Streaming) |
|---|---|---|
| Tempo do Primeiro Byte | 1.700 ms | 50 ms (shell estático) |
| Interatividade da primeira tela | 4,2s | 1,1s |
| Bloqueio por bloco grande | Todos | ✅ Nenhum |
| Experiência do Usuário | Tela em branco por 4 segundos | Tela de skeleton → Preenche bloco por bloco |
3. Estratégia de Divisão de Suspense Boundary
(1) As Três Camadas de Carregamento de uma Página
graph TB
A[Página] --> B[Camada 1: Shell estático<br/>layout + header<br/>Pré-visualização Instantânea]
A --> C[Camada 2: Exibição de Wireframe<br/>loading.tsx<br/>~200ms]
A --> D[Camada 3: Conteúdo<br/>Suspense Boundary<br/>Chegada em Streaming Bloco por Bloco]
B --> E[Usuários veem a estrutura da página]
C --> F[Usuários veem uma animação de placeholder]
D --> G[Preenche o conteúdo em ordem]
style B fill:#d4edda
style C fill:#fff3cd
style D fill:#cce5ff
| Camada | Mecanismo | Tempo de Exibição | Percepção do Usuário |
|---|---|---|---|
| Shell Estático | Layout | Tempo real | Estrutura da Página |
| Tela de Skeleton | loading.tsx |
~200 ms | Animação de carregamento |
| Conteúdo | Suspense + fallback | Renderização bloco por bloco | Carregamento progressivo |
(2) Carregamento de Dados Serial vs. Paralelo
// ❌ Cascata Serial — Lento
export default async function SlowPage() {
const users = await fetch('https://api.example.com/users').then(r => r.json())
const projects = await fetch('https://api.example.com/projects').then(r => r.json())
const analytics = await fetch('https://api.example.com/analytics').then(r => r.json())
return <Dashboard users={users} projects={projects} analytics={analytics} />
}
// ✅ Suspense Paralelo — Rápido
export default function FastPage() {
return (
<div>
<Suspense fallback={<SkeletonCards />}><UserCards /></Suspense>
<Suspense fallback={<SkeletonCards />}><ProjectCards /></Suspense>
<Suspense fallback={<ChartSkeleton />}><AnalyticsChart /></Suspense>
</div>
)
}
▶ Exemplo: Implementando o componente Suspense
Saída:
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
Fallback: }>
Visible text: }> | }> | }>
// components/UserCards.tsx — Suspense Boundary Independente
export default async function UserCards() {
// Simulando Consultas Lentas
const users = await new Promise<{ name: string; email: string }[]>((resolve) =>
setTimeout(() => resolve([
{ name: 'Alice', email: 'alice@taskflow.io' },
{ name: 'Bob', email: 'bob@taskflow.io' },
{ name: 'Charlie', email: 'charlie@taskflow.io' }
]), 2000)
)
return (
<div style={{ display: 'flex', gap: '1rem' }}>
{users.map((u) => (
<div key={u.email} style={{ border: '1px solid #ccc', padding: '1rem', borderRadius: 8 }}>
<h3>{u.name}</h3>
<p>{u.email}</p>
</div>
))}
</div>
)
}
Saída:
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
// components/SkeletonCards.tsx — Fallback de Exibição de Wireframe
export default function SkeletonCards() {
return (
<div style={{ display: 'flex', gap: '1rem' }}>
{[1, 2, 3].map((i) => (
<div key={i} style={{
width: 200, height: 100, borderRadius: 8,
background: 'linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s infinite'
}} />
))}
</div>
)
}
4. loading.tsx e Suspense Automático
(1) Convenções do Arquivo
| Arquivo | Finalidade | Escopo do Suspense |
|---|---|---|
app/dashboard/loading.tsx |
Envolve toda a rota da página | Todo o conteúdo de page.tsx |
app/dashboard/settings/loading.tsx |
Apenas o segmento settings | settings/page.tsx |
graph TB
A[app/dashboard/] --> B[layout.tsx<br/>Layout Raiz]
A --> C[loading.tsx<br/>Suspense no Nível da Página]
A --> D[page.tsx<br/>Conteúdo da Página]
D --> E{Dentro da página}
E --> F[<Suspense><SlowWidget/></Suspense>]
E --> G[<Suspense><AnotherWidget/></Suspense>]
C --> H[Exibir fallback de loading.tsx]
D --> I[Exibir Conteúdo de page.tsx]
F --> J[Carregamento em Streaming Independente]
style C fill:#fff3cd
style F fill:#cce5ff
style G fill:#cce5ff
(2) Design do loading.tsx
// app/dashboard/loading.tsx — Tela de Skeleton no Nível da Página
export default function DashboardLoading() {
return (
<div style={{ padding: '2rem' }}>
<div style={{ height: 32, width: 200, background: '#eee', borderRadius: 4, marginBottom: '2rem' }} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '1rem' }}>
{[1, 2, 3].map((i) => (
<div key={i} style={{ height: 120, background: '#f5f5f5', borderRadius: 8 }} />
))}
</div>
<div style={{ height: 300, background: '#f5f5f5', borderRadius: 8, marginTop: '2rem' }} />
</div>
)
}
▶ Exemplo: Hierarquia de loading.tsx aninhados
app/dashboard/ ← loading.tsx Página inteira
├── layout.tsx ← Barra de Navegação (Pré-visualização Instantânea)
├── loading.tsx ← Tela de Skeleton da Página
├── page.tsx ← Conteúdo do Dashboard
├── projects/ ← Sub-rota
│ ├── loading.tsx ← Tela de skeleton apenas para a lista de projetos
│ └── page.tsx ← Lista de Projetos
└── settings/
└── loading.tsx ← Tela de Skeleton de Configurações
// app/dashboard/projects/loading.tsx — Estado de carregamento apenas para a lista de projetos
export default function ProjectsLoading() {
return (
<div>
{[1, 2, 3, 4].map((i) => (
<div key={i} style={{
height: 64, marginBottom: 8, borderRadius: 6,
background: 'linear-gradient(90deg, #e8e8e8 0%, #f5f5f5 50%, #e8e8e8 100%)',
backgroundSize: '200% 100%',
animation: 'shimmer 1.5s ease-in-out infinite'
}} />
))}
</div>
)
}
5. Design de Suspense Aninhado e Fallback
(1) Estratégias de Aninhamento
graph TB
A[Página] --> B[Suspense da camada externa<br/>fallback: Estrutura da Página]
A --> C[Suspense da camada interna 1<br/>fallback: Template de Cartão]
A --> D[Suspense da camada interna 2<br/>fallback: Estrutura do Gráfico]
D --> E[Suspense de nível mais profundo<br/>fallback: Micro-estrutura]
B -->|Ver Agora| F[Estrutura da Página]
C -->|~500ms| G[Cartão de Usuário]
D -->|~800ms| H[Gráficos Estatísticos]
E -->|~1200ms| I[Detalhes do Gráfico]
style B fill:#f8d7da
style C fill:#fff3cd
style D fill:#cce5ff
style E fill:#d4edda
| Nível de Aninhamento | Fallback Recomendado | Tempo de Exibição | Densidade de Informação |
|---|---|---|---|
| Camada Externa (Página) | Placeholder Grande | Instantâneo | Baixa (Estrutura) |
| Camada Média (Componente) | Skeleton em Forma de Componente | ~500 ms | Média (Contorno) |
| Camada Interna (Detalhes) | Placeholder Pequeno + Micro-animação | ~1200 ms | Alta (Conteúdo) |
▶ Exemplo: Suspense aninhado em três níveis
Saída:
Diagram of nested Suspense: outer fallback (page shell) + inner fallback (section loading).
// app/analytics/page.tsx — Aplicação Prática de Suspense Aninhado
import { Suspense } from 'react'
function SummarySkeleton() { return <div style={{ height: 100, background: '#eee' }} /> }
function ChartSkeleton() { return <div style={{ height: 300, background: '#f5f5f5' }} /> }
function DetailSkeleton() { return <div style={{ height: 60, background: '#fafafa' }} /> }
export default function AnalyticsPage() {
return (
<div>
<h1>Relatório de Análise</h1>
{/* Camada externa: Cartão de Visão Geral */}
<Suspense fallback={<SummarySkeleton />}>
<SummaryCards />
</Suspense>
{/* Camada média: Gráficos */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart />
</Suspense>
{/* Camada interna: Lista de Detalhes */}
<Suspense fallback={<DetailSkeleton />}>
<TopProjects />
</Suspense>
</div>
)
}
async function SummaryCards() {
await new Promise((r) => setTimeout(r, 500))
return <div>Receita deste Mês: $120.000 • Número de usuários: 15.230 • Projeto: 342</div>
}
async function RevenueChart() {
await new Promise((r) => setTimeout(r, 1000))
return <div style={{ height: 300, background: '#e8f4f8' }}>[Gráficos: Tendências de Receita Mensal]</div>
}
async function TopProjects() {
await new Promise((r) => setTimeout(r, 1500))
return <div>Principais Projetos: TaskFlow (45%), WebApp (30%), Mobile (25%)</div>
}
Saída:
Renders static shell immediately. Dynamic sections show "Loading..." fallback until data loads.
Visible content: Relatório de Análise | }> | }>
6. Hook use() do React 19 para Leitura de Promises
Comparação entre use() vs await
| Característica | await (Server Component) |
use() (Client Component) |
|---|---|---|
| Localização | Apenas Server Component | Client Component (incluindo 'use client') |
| Comportamento de bloqueio | Bloqueia a renderização do componente | Lança uma Promise → Suspense captura |
| Assinatura de Tipo | const data = await promise |
const data = use(promise) |
| Reenvio | Automático (reexecução do RSC) | Deve ser acionado manualmente |
// ✅ Server Component: await
async function ServerProfile({ id }: { id: string }) {
const user = await fetch(`https://api.example.com/users/${id}`).then(r => r.json())
return <div>{user.name}</div>
}
// ✅ Client Component: use()
'use client'
import { use } from 'react'
function ClientProfile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise)
return <div>{user.name}</div>
}
(2) Usando use() com Streaming no Client Component
// components/StreamingProfile.tsx — use() + Suspense
'use client'
import { use } from 'react'
interface User { name: string; email: string; bio: string }
function UserProfile({ promise }: { promise: Promise<User> }) {
const user = use(promise)
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
<p>{user.bio}</p>
</div>
)
}
// Uso na página
import { Suspense } from 'react'
export default function ProfilePage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params) // params também é Promise
const userPromise = fetch(`https://api.example.com/users/${id}`).then(r => r.json())
return (
<Suspense fallback={<div>Carregando perfil do usuário...</div>}>
<UserProfile promise={userPromise} />
</Suspense>
)
}
params quanto searchParams são Promises; você deve desempacotá-los usando await (RSC) ou use() (Client).
▶ Exemplo: use() Implementando um feed de scroll infinito
Saída:
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
// components/InfinitePosts.tsx
'use client'
import { use, useState, useTransition } from 'react'
interface Post { id: number; title: string }
async function fetchPosts(page: number): Promise<Post[]> {
const res = await fetch(`/api/posts?page=${page}&limit=10`)
return res.json()
}
export default function InfinitePosts({ initialPromise }: { initialPromise: Promise<Post[]> }) {
const [page, setPage] = useState(1)
const [postsPromise, setPostsPromise] = useState(initialPromise)
const [isPending, startTransition] = useTransition()
const posts = use(postsPromise)
const loadMore = () => {
startTransition(() => {
setPage((p) => p + 1)
setPostsPromise(fetchPosts(page + 1))
})
}
return (
<div>
{posts.map((post) => <div key={post.id}>{post.title}</div>)}
<button onClick={loadMore} disabled={isPending}>
{isPending ? 'Carregando......' : 'Carregar Mais'}
</button>
</div>
)
}
Saída:
Renders: InfinitePosts page with interactive UI elements.
7. Integração com AI SDK StreamText
(1) Arquitetura de Streaming do streamText
graph LR
A[Mensagens do Usuário] --> B[Route Handler<br/>POST /api/chat]
B --> C[AI SDK streamText]
C --> D[Provedor LLM<br/>OpenAI / Anthropic]
D -->|Token de Fluxo| E[ReadableStream]
E --> F[Client Component<br/>Hook useChat]
F --> G[Efeito de Máquina de Escrever]
style B fill:#cce5ff
style C fill:#d4edda
style F fill:#fff3cd
| Componente | Função | Instalação |
|---|---|---|
Biblioteca Core ai |
Funções streamText |
npm install ai |
@ai-sdk/openai |
Provedor OpenAI | npm install @ai-sdk/openai |
useChat |
Hook do Cliente | Incluído no pacote ai |
(2) Rota de Streaming no Servidor
// app/api/chat/route.ts — API de Chat com IA ao Vivo
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: openai('gpt-4o'),
system: 'Você é o Assistente de IA do TaskFlow. Responda perguntas relacionadas ao gerenciamento de projetos.',
messages
})
return result.toDataStreamResponse()
}
▶ Exemplo: Efeito de máquina de escrever no lado do cliente
Saída:
TypeScript code executed successfully.
// components/ChatBox.tsx
'use client'
import { useChat } from 'ai/react'
export default function ChatBox() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat()
return (
<div style={{ maxWidth: 600, margin: '0 auto' }}>
<div style={{ height: 400, overflowY: 'auto', border: '1px solid #ccc', padding: '1rem' }}>
{messages.map((m) => (
<div key={m.id} style={{
textAlign: m.role === 'user' ? 'right' : 'left',
marginBottom: '1rem'
}}>
<strong>{m.role === 'user' ? 'Você' : 'IA'}:</strong>
<p>{m.content}</p>
</div>
))}
{isLoading && <p>IA Digitando......</p>}
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', marginTop: '1rem' }}>
<input
value={input}
onChange={handleInputChange}
placeholder="Digite sua pergunta..."
style={{ flex: 1, padding: '0.5rem' }}
/>
<button type="submit" disabled={isLoading} style={{ padding: '0.5rem 1rem' }}>
Enviar
</button>
</form>
</div>
)
}
Saída:
Renders a list by mapping over messages, displaying each m.
useChat é POST /api/chat. Para especificar um endpoint de API personalizado, passe a opção api: useChat({ api: '/api/ai/chat' }).
8. Exemplo Completo: Dashboard de Análise com IA + Dados em Streaming
// app/dashboard/page.tsx — Dashboard em Fluxo + Análise com IA
import { Suspense } from 'react'
import { auth } from '@/auth'
import { redirect } from 'next/navigation'
// Módulos de Exibição de Estrutura
function MetricSkeleton() {
return <div style={{ height: 100, background: '#f0f0f0', borderRadius: 8 }} />
}
function ChartSkeleton() {
return <div style={{ height: 300, background: '#f5f5f5', borderRadius: 8 }} />
}
// Componente de Dados Lentos
async function TeamMetrics() {
const metrics = await new Promise<{ members: number; projects: number; tasks: number }>(
(resolve) => setTimeout(() => resolve({ members: 12, projects: 45, tasks: 230 }), 1500)
)
return (
<div style={{ display: 'flex', gap: '1rem' }}>
<div>👥 {metrics.members} Membros</div>
<div>📁 {metrics.projects} Projetos</div>
<div>✅ {metrics.tasks} Tarefas</div>
</div>
)
}
async function ActivityChart() {
const data = await new Promise<number[]>((r) => setTimeout(() => r([30, 45, 78, 92, 55, 88, 120]), 2000))
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '0.5rem', height: 200 }}>
{data.map((v, i) => (
<div key={i} style={{ height: v, width: 40, background: '#4f46e5', borderRadius: '4px 4px 0 0' }} />
))}
</div>
)
}
export default async function DashboardPage() {
const session = await auth()
if (!session) redirect('/login')
return (
<div>
<h1>Visão Geral do TaskFlow</h1>
<p>Bem-vindo de volta, {session.user!.name}</p>
<Suspense fallback={<MetricSkeleton />}>
<TeamMetrics />
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<ActivityChart />
</Suspense>
</div>
)
}
// app/api/chat/route.ts — Assistente de Análise com IA
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: openai('gpt-4o-mini'),
system: 'Você é um Assistente de IA de gerenciamento de projetos. Com base nos dados do projeto fornecidos, forneça análises e recomendações. Mantenha suas respostas breves.',
messages
})
return result.toDataStreamResponse()
}
// components/ChatPanel.tsx
'use client'
import { useChat } from 'ai/react'
export default function ChatPanel() {
const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat()
return (
<div style={{ position: 'fixed', bottom: 0, right: 20, width: 380, border: '1px solid #ccc', borderRadius: '8px 8px 0 0' }}>
<div style={{ padding: '0.5rem 1rem', background: '#4f46e5', color: '#fff', borderRadius: '8px 8px 0 0' }}>
Assistente de Análise com IA
</div>
<div style={{ height: 300, overflowY: 'auto', padding: '0.5rem' }}>
{messages.map((m) => (
<div key={m.id} style={{ marginBottom: '0.5rem' }}>
<strong>{m.role === 'user' ? 'Eu' : 'IA'}:</strong>
<p style={{ margin: 0 }}>{m.content}</p>
</div>
))}
</div>
<form onSubmit={handleSubmit} style={{ display: 'flex', borderTop: '1px solid #eee' }}>
<input value={input} onChange={handleInputChange} placeholder="Faça uma Pergunta Sobre o Projeto..." style={{ flex: 1, padding: '0.5rem', border: 'none' }} />
<button type="submit" disabled={isLoading} style={{ padding: '0.5rem 1rem', background: '#4f46e5', color: '#fff', border: 'none' }}>Enviar</button>
</form>
</div>
)
}
❓ Perguntas Frequentes
P: Qual é a diferença entre Suspense e loading.tsx? R:
loading.tsxé uma convenção de arquivo que cria automaticamente uma boundary de Suspense para toda a página, tornando a implementação simples. Componentes<Suspense>são usados para controle refinado dentro de uma página; eles podem envolver qualquer número de blocos de dados independentes para streaming paralelo.
P: A renderização em streaming afeta o SEO? R: Não. Os rastreadores de mecanismos de busca (Googlebot) esperam até que o HTML final esteja completo antes de indexá-lo; o conteúdo em streaming é carregado progressivamente, não injetado posteriormente. O RSC Payload do Next.js garante que os rastreadores vejam o conteúdo completo.
P: Quando
use()é melhor queawait? R:use()pode ser usado em client components, permitindo que o próprio componente declare sua dependência de uma Promise, com a boundary de Suspense mais próxima tratando o estado de carregamento. É adequado para cenários onde operações assíncronas precisam ser acionadas no front-end (como clicar em "Carregar Mais").
P: Qual é a diferença entre usar o método
streamTextdo AI SDK e chamar a API da OpenAI diretamente? R:streamTexttrata automaticamente o protocolo SSE (Server-Sent Events), controle de backpressure, contagem de tokens e novas tentativas de erro. Chamar a API da OpenAI diretamente requer tratamento manual deReadableStreame do formato de resposta.
P: Como a renderização em streaming e o PPR (Partial Prerendering) funcionam juntos? R: O shell estático no PPR consiste no layout externo e conteúdo estático, enquanto as partes dinâmicas internas são envolvidas em
<Suspense>. O PPR pré-gera as partes estáticas, e as partes dinâmicas são renderizadas sob demanda — os dois se complementam perfeitamente.
📖 Resumo
- Suspense divide a página em blocos de streaming independentes, evitando que requisições seriais bloqueiem toda a página
- O arquivo
loading.tsxcria automaticamente um Suspense para a página da rota, com um fallback exibindo uma tela de skeleton - Estratégia de Suspense aninhado: Skeleton externo (imediato) → Contorno médio (~500 ms) → Detalhes internos (~1200 ms)
- O Hook
use()do React 19 consome Promises em Client Components, usado com Suspense - AI SDK
streamText+useChatpara conversas de IA em streaming com efeito estilo máquina de escrever - Combinando renderização em streaming com shells estáticos PPR: pré-renderizando partes estáticas + streaming de partes dinâmicas
📝 Exercícios
-
Exercício Básico (⭐): Implemente três boundaries de Suspense em uma única página para carregar a lista de usuários, estatísticas de projetos e log de atividades, respectivamente, com cada boundary tendo um atraso diferente (500 ms / 1000 ms / 1500 ms).
-
Exercício Avançado (⭐⭐): Use o
streamTextdo AI SDK para criar uma rota de API de assistente de tradução, e useuseChatno lado do cliente para implementar um efeito de máquina de escrever que exiba os resultados da tradução caractere por caractere. -
Desafio (⭐⭐⭐): Implemente uma página de dashboard com múltiplos níveis de aninhamento: A camada externa
loading.tsxexibe a visão de skeleton de página inteira, com três camadas aninhadas de Suspense dentro da página (cartão de estatísticas → gráfico → lista detalhada). A camada mais externa usa o hookuse()para carregar mais dados ao clicar.