Paginação e infinite scroll com React Server Components, Suspense e streaming SSR
Por John Pires · 07 de março de 2026

Cenario prático do dia a dia
Você tem uma página de listagem (feed, catálogo, issues) que precisa carregar centenas ou milhares de itens. O produto exige bom SEO para a primeira carga, layout estável e uma UX fluida ao rolar com infinite scroll. O time já tentou soluções client-side com useEffect e fetch direto no navegador, mas enfrenta reflows, pedidos duplicados, e tempo até o primeiro paint ruim.
Vou analisar o jeito errado — o antipadrão que vejo em code reviews — e depois mostrar um caminho pragmático usando React Server Components (RSC), Suspense e streaming SSR para melhorar tempo de primeira pintura, reduzir costura de JS e manter interatividade por meio de um componente cliente mínimo.
Este não é um tutorial do zero sobre RSC: assumo que você conhece os conceitos básicos (componentes server/client, streaming SSR, Suspense). Foco em patterns, armadilhas e implementações realistas que você pode adaptar.
Por que resolver no server?
Renderizar a primeira página no server garante HTML indexável e reduz TTFB percebido para o conteúdo principal. Streaming SSR permite enviar blocos de conteúdo conforme ficam prontos, evitando bloquear toda a página por uma única operação lenta.
Requisitos funcionais do exemplo
Carregar a primeira página no server (SEO + velocidade);
Stream de páginas subsequentes quando possível (progressive hydration);
Infinite scroll client-side sem refetching desnecessário;
Tratamento robusto de erros e backoff quando a API falha;
Baixo bundle client (componentes UI mínimos como client components).
Arquitetura da solução
Arquitetura proposta (alto nível):
Server Component de página (route) que renderiza a primeira página de itens.
Dentro do server component, usar Suspense + componentes assíncronos para permitir streaming SSR de blocos (cada bloco = uma página de itens).
Componente cliente pequeno que atua como "controller" do infinite scroll: detecta scroll, faz fetch da próxima página via API endpoint e injeta no DOM local (append). Usa startTransition e placeholders locais para evitar layout shift.
API route paginada que responde JSON com cursor/nextCursor e dados; o server component usa a mesma API ou uma função de banco para popular conteúdo no build/SSR.
Componentes e responsabilidades
Page.server.jsx — entry server component. Renderiza header e chamada inicial ao ItemStream.server.jsx.
ItemStream.server.jsx — monta Suspense boundaries por página, permitindo streaming.
ItemList.server.jsx — componente assíncrono que busca uma página e retorna markup de itens.
InfiniteScroll.client.jsx — componente client que detecta scroll, chama /api/items?page=X e anexa itens no DOM; usa startTransition para suavizar updates.
app/api/items/route.js — API paginada (cursor/limit).
Comparação rápida: errado x certo
Errado: buscar tudo no cliente com useEffect e renderizar — lenta primeira pintura, SEO ruim, comportamento de rede duplicado e falta de streaming.
Certo: renderizar primeira página no server, stream opcionalmente páginas adicionais no SSR e delegar apenas interação incremental ao cliente. Mantém bundle pequeno e melhora TTFB e CLS.
Codigo completo com explicacoes
Vou mostrar trechos reais. Contexto: app Next.js App Router (React 18+). Ajuste para seu framework se necessário.
Antipadrão — carregamento completamente client-side
O código abaixo ilustra o que você não deve fazer para listagens grandes em produção. Simples mas problemático.
// components/BadInfinite.client.jsx
import React, {useEffect, useState} from 'react';
export default function BadInfinite(){
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
useEffect(()=>{
// antipadrão: fetch em cada montagem e sem debounce/locks
fetch(`/api/items?page=${page}`)
.then(r=>r.json())
.then(data=> setItems(prev => [...prev, ...data.items]));
}, [page]);
useEffect(()=>{
const onScroll = ()=>{
if(window.innerHeight + window.scrollY >= document.body.offsetHeight - 200){
setPage(p=>p+1);
}
}
window.addEventListener('scroll', onScroll);
return ()=> window.removeEventListener('scroll', onScroll);
}, []);
return (
{items.map(i=> {i.title})}
);
}O que acontece aqui:
Primeira pintura não contém os itens (dependência no JS do cliente);
Nenhuma proteção contra pedidos duplicados se o usuário rolar rápido;
Sem interleaving com SSR/Streaming — SEO prejudicado.
Tip: evitar operar tudo no cliente em páginas que precisam ser indexadas ou que carregam muitos itens.
Solução correta: streaming SSR + client minimal para infinite scroll
Segue implementação compatível com Next.js App Router. Os componentes server assumem funções async e são capazes de aguardar fetch sem bloquear o envio de outros boundaries.
Primeiro, API route paginada (simulada). Este endpoint retorna items, nextCursor e total.
// app/api/items/route.js
import { NextResponse } from 'next/server';
// mock DB function — no seu projeto troque por acesso real ao DB
async function fetchItemsFromDb({cursor = 0, limit = 20}){
// simula latência
await new Promise(res=>setTimeout(res, 120));
const start = Number(cursor) || 0;
const items = Array.from({length: limit}, (_,i)=> ({
id: start + i + 1,
title: `Item ${(start + i + 1)}`,
body: `Conteúdo do item ${start + i + 1}`
}));
const nextCursor = start + limit < 1000 ? start + limit : null; // example
return { items, nextCursor };
}
export async function GET(request){
const { searchParams } = new URL(request.url);
const cursor = searchParams.get('cursor') || 0;
const limit = parseInt(searchParams.get('limit') || '20', 10);
const data = await fetchItemsFromDb({cursor, limit});
return NextResponse.json(data);
}
O que este endpoint faz: retorna páginas determinísticas e um cursor. Mantemos o esquema simples (cursor offset), mas prefira cursors opacos em produção.
Agora o server component que usa Suspense para streaming de páginas. A ideia: o parent rende vários ItemPage.server em Suspense — cada um busca sua página assincronamente e pode stream quando estiver pronto.
// components/ItemPage.server.jsx
import React from 'react';
// ItemPage é um server component assíncrono: recebe cursor e faz fetch server-side
export default async function ItemPage({cursor, limit = 20}){
const res = await fetch(`http://localhost:3000/api/items?cursor=${cursor}&limit=${limit}`, { cache: 'no-store' });
if(!res.ok) throw new Error('failed to fetch page');
const data = await res.json();
return (
{data.items.map(item => (
{item.title}{item.body}
)
)}
);
}Componente que monta a stream (pode renderizar 2 páginas inicialmente e preparar mais boundaries):
// components/ItemStream.server.jsx
import React, { Suspense } from 'react';
import ItemPage from './ItemPage.server';
export default function ItemStream({initialCursor = 0, pagesToStream = 3, pageSize = 20}){
const cursors = [];
for(let i=0;i
{cursors.map((c, idx) => (
// cada ItemPage é uma Suspense boundary — permite streaming SSR quando cada promise resolve
carregando página {idx+1}...}>
{/* ItemPage é server component assíncrono */}
))}
);
}
O que acontece aqui:
O server vai começar a renderizar cada ItemPage; quando cada fetch resolver, seu HTML é enviado ao cliente imediatamente (streaming SSR).
Fallbacks garantem que o layout do bloco exista antes do conteúdo, reduzindo CLS.
Por fim, o component client que gerencia o infinite scroll para requisições a partir do cliente (após as páginas iniciais server-rendered):
// components/InfiniteScroll.client.jsx
'use client';
import React, {useEffect, useRef, useState, useTransition} from 'react';
export default function InfiniteScroll({startCursor}){
const [items, setItems] = useState([]);
const [cursor, setCursor] = useState(startCursor);
const [isPending, startTransition] = useTransition();
const loadingRef = useRef(false);
const finishedRef = useRef(false);
async function loadNext(){
if(loadingRef.current || finishedRef.current) return;
loadingRef.current = true;
try{
const res = await fetch(`/api/items?cursor=${cursor}`);
if(!res.ok) throw new Error('network');
const data = await res.json();
startTransition(()=>{
setItems(prev => [...prev, ...data.items]);
setCursor(data.nextCursor || null);
});
if(!data.nextCursor) finishedRef.current = true;
}catch(err){
console.error('load failed', err);
// backoff simples: esperar antes de liberar
await new Promise(r=>setTimeout(r, 1000));
}finally{
loadingRef.current = false;
}
}
useEffect(()=>{
const onScroll = ()=>{
if(window.innerHeight + window.scrollY >= document.body.offsetHeight - 300){
loadNext();
}
}
window.addEventListener('scroll', onScroll);
return ()=> window.removeEventListener('scroll', onScroll);
}, [cursor]);
return (
{items.map(it=> (
{it.title}{it.body}
))}
{isPending && Carregando...}
{!cursor && Fim da lista}
);
}Integração na rota principal (app/page.jsx):
// app/page.jsx (Server Component)
import React from 'react';
import ItemStream from '../components/ItemStream.server';
import InfiniteScroll from '../components/InfiniteScroll.client';
export default function Page(){
// renderiza 2 páginas via streaming e o client component inicia com cursor=40 por exemplo
return (
Feed
{/* client component usa cursor após páginas server-rendered */}
);
}Resultado prático:
Primeiras N páginas chegam como HTML no primeiro render (ótimo para SEO e rápido paint).
Suspense fallback evita layout jump e permite streaming de blocos independentes.
Componente client é responsável apenas por buscas incrementais e interatividade — bundle mínimo.
Tratamento de erros e edge cases
Listo aqui os problemas reais que aparecem em produção e como mitigá-los.
Erros comuns
1) Pedido duplicado: quando o usuário rola rápido o suficiente para disparar múltiplos fetches simultâneos. Solução: lock/flag (loadingRef) no client, ou deduplicação com SWR / React Query.
2) Falhas intermitentes de rede: sem backoff você vai encher logs e dar má experiência. Solução: retries exponenciais limitados e informar o usuário.
3) Cursor inválido: se o cursor for offset e o dataset muda, você terá itens duplicados ou pulados. Solução: usar cursors estáveis (cursor baseado em timestamp ou ID) ou revalidação.
Como capturar erros no server component
Server components podem propagar exceções. No Next.js use error.js para rotas ou boundaries de erro. Não exponha stacktrace para o cliente.
Tip: trate erros esperados (404 de cursor) como estado e exiba fallback amigável. Reserve error pages para falhas não esperadas.
Exemplo de manejo local no fetch do server component:
// exemplo de fetch robusto dentro de ItemPage.server
async function safeFetch(url){
try{
const res = await fetch(url, { cache: 'no-store' });
if(!res.ok){
if(res.status === 404) return { items: [], nextCursor: null };
throw new Error('fetch failed ' + res.status);
}
return await res.json();
}catch(err){
// registre no servidor, retorne fallback
console.error('fetch error', err);
return { items: [], nextCursor: null };
}
}
Se houver uma falha em um bloco stream, o fallback do Suspense é exibido e o restante da página continua renderizando. Isso é uma vantagem do streaming: uma operação lenta não bloqueia o resto.
Edge cases de UX
Usuário volta para o topo: dimensione o cache local de itens para evitar re-fetch desnecessário;
Usuário abre várias abas: limite requisições por sessão;
Troca de filtros: reinicialize cursor e cancele requisições pendentes (AbortController no cliente).
Performance e otimização
Foco em reduzir JS enviado ao cliente, diminuir latência percebia e evitar layout shift.
Reduzir bundle client
Mantenha o componente client mínimo: apenas lógica de scroll/interação. Use server components para renderizar markup. Evite grandes bibliotecas client-side para detecção de scroll — um pequeno utilitário custom é muitas vezes suficiente.
Cache e revalidação
Algumas opções para fetch em server components:
cache: 'force-cache' para conteúdo pouco mutável;
cache: 'no-store' + CDN/Edge caching se precisar de dados sempre frescos;
stale-while-revalidate via controle de headers no seu API para acelerar requests subsequentes.
Escolha baseado na taxa de mudança dos dados. Para feeds sociais use cache curto e revalidação agressiva.
Minimizar reflows
Use placeholders com altura conhecida nos fallbacks de Suspense. Isso reduz CLS porque o navegador já reserva espaço para o conteúdo que vai chegar.
Debounce e throttling no cliente
Não dispare loadNext sem controle. Um débounce de 200-300ms ou um threshold impede múltiplos fetches quando o usuário faz scroll violento.
Mensuração
Monitore:
TTFB e LCP para a primeira página;
Time-to-interactive do componente client;
Requests por sessão para o endpoint de items (identificar clientes que geram muito tráfego);
Instrumente com metrics de servidor e RUM no cliente.
Conclusão prática
Resumo direto: não entregue tudo ao cliente. Use Server Components para renderizar a primeira página e Suspense + streaming para enviar blocos de conteúdo conforme estiverem prontos. Mantenha o componente client pequeno — ele só precisa controlar fetch incremental, locks para evitar duplicação, retries/backoff e cancelamento.
Erros comuns que vejo em code reviews: renderizar apenas no cliente, não usar locks no fetch e não tratar falhas de rede. Corrija isso com flags simples, AbortController e retries limitados.
Implante a arquitetura proposta por etapas: comece renderizando a primeira página no server; em seguida troque list-items por boundaries Suspense para streaming; finalmente extraia a lógica de scroll para um client component leve.
Resultados esperados: melhor SEO, TTFB reduzido, menor bundle client e UX mais suave com menos layout shifts.
Checklist rápido para code review:
Primeira renderização server-side (HTML) confirmada;
Suspense fallbacks com altura fixa para prevenir CLS;
Client component minimal com locks, startTransition e backoff;
Endpoint paginado com cursor estável;
Logging e métricas para falhas e latência.
Execute essas mudanças incrementais e meça antes/depois. Patterns aqui são pragmáticos: não prometem mágica, mas resolvem os problemas que aparecem em produção quando se tenta escalar listagens com infinite scroll.
Assuntos: React, server-components, suspense, ssr, infinite-scroll
Leia também
Sincronizando estado entre abas com BroadcastChannel e useSyncExternalStore
Aprenda a sincronizar estado entre abas no browser usando BroadcastChannel e useSyncExternalStore do React. Guia passo a passo, exemplo funcional de contador…
Sincronizar estado entre Server e Client Components com use e useSyncExternalStore
Padronize estado entre Server e Client Components usando use (servidor) e useSyncExternalStore (cliente). Evite fetch duplicado, hydrate sem mismatch e…
Suavizando transições em listas React com startTransition, useDeferredValue e Suspense
Como evitar travamentos em listas React: use startTransition para priorizar atualizações, useDeferredValue para adiar derivação de valores, e Suspense para…
Melhorar LCP com React Server Components e Suspense: streaming, placeholders e revalidação
Reduza LCP com React Server Components e Suspense: prioridades de dados, streaming incremental, placeholders que preservam layout e revalidação de cache…
Corrigindo mismatches de hydration no React App Router (SSR): passo a passo
Guia prático e direto para identificar e corrigir mismatches de hydration no React App Router (SSR). Passo a passo: ambiente, exemplos, mini-projeto com…
Criando useStableCallback (useEvent): callbacks estáveis no React
Aprenda a criar useStableCallback, um hook que fornece callbacks com identidade estável mas que chamam a versão mais recente do handler. Mostro o erro comum…