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



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 await dentro 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.

TSX
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

100%
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

TSX
// ❌ 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:

TEXT 📖 Somente leitura
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
Fallback: }>
Visible text: }> | }> | }>
TSX
// 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:

TEXT 📖 Somente leitura
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
TSX
// 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
100%
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

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

TEXT 📖 Somente leitura
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
TSX
// 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

100%
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:

TEXT 📖 Somente leitura
Diagram of nested Suspense: outer fallback (page shell) + inner fallback (section loading).
TSX
// 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:

TEXT 📖 Somente leitura
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
TSX
// ✅ 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

TSX
// 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>
  )
}
💡 Dica: No Next.js 16, tanto 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:

TEXT 📖 Somente leitura
Renders a static shell immediately, with dynamic content loading inside Suspense boundaries.
TSX
// 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:

TEXT 📖 Somente leitura
Renders: InfinitePosts page with interactive UI elements.


7. Integração com AI SDK StreamText

(1) Arquitetura de Streaming do streamText

100%
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

TS
// 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:

TEXT 📖 Somente leitura
TypeScript code executed successfully.
TSX
// 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:

TEXT 📖 Somente leitura
Renders a list by mapping over messages, displaying each m.
🔥 Erro Comum: O padrão do 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

TSX
// 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>
  )
}
TS
// 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()
}
TSX
// 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>
  )
}
💻 Descrição do Efeito: A página primeiro exibe uma visão de skeleton → Após 0,5 segundos, os cartões de métricas são preenchidos → Após 1 segundo, o gráfico de barras aparece → O painel de IA aparece à direita para conversa em tempo real, com conteúdo exibido em streaming palavra por palavra.


❓ 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 que await? 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 streamText do AI SDK e chamar a API da OpenAI diretamente? R: streamText trata 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 de ReadableStream e 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


📝 Exercícios

  1. 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).

  2. Exercício Avançado (⭐⭐): Use o streamText do AI SDK para criar uma rota de API de assistente de tradução, e use useChat no lado do cliente para implementar um efeito de máquina de escrever que exiba os resultados da tradução caractere por caractere.

  3. Desafio (⭐⭐⭐): Implemente uma página de dashboard com múltiplos níveis de aninhamento: A camada externa loading.tsx exibe 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 hook use() para carregar mais dados ao clicar.

Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%