Next.js: Partial Prerendering (PPR)
Última atualização: 2026-08-26
PPR é o modo de renderização mais revolucionário no Next.js 16 — ele permite que uma única página combine a resposta ultrarrápida de um shell estático com conteúdo em tempo real em seções dinâmicas.
1. O Que Você Vai Aprender
- O Conceito PPR: Uma Página = Shell Estático + Conteúdo Dinâmico em Streaming
- Como Configurar o PPR
- Suspense: O Princípio Central dos Limites Dinâmicos
- Comparação de Performance entre PPR, ISR e SSR
- Design Prático de PPR para Cenários de Dashboard
2. Uma História Real de uma Arquiteta
(1) Ponto de Dor: O dashboard ou é totalmente estático (dados desatualizados) ou totalmente dinâmico (lento)
Diana é arquiteta na equipe TaskFlow. O dashboard SaaS da empresa enfrenta um dilema:
- Abordagem SSG: A página inteira é gerada durante o build — a barra de navegação, sidebar e informações do usuário estão todas desatualizadas. "Bem-vinda de volta, Alice!" exibe dados de 3 dias atrás.
- Abordagem SSR: Renderiza novamente a cada requisição — a página leva 4 segundos para carregar porque consultas ao banco de dados e chamadas de API são executadas sequencialmente
O que ela quer é que a barra de navegação/sidebar/layout seja estática (gerada no build e cacheada via CDN), e que dados do usuário/notificações sejam dinâmicos (buscados em tempo real). No entanto, SSG ou SSR tradicionais só permitem um ou outro — uma única página só pode ter um modo de renderização.
(2) Solução com PPR
Use PPR para dividir o Dashboard em um shell estático (Layout + Navegação) e uma área dinâmica (limite Suspense).
// app/dashboard/page.tsx
import { Suspense } from 'react'
import { NavBar } from '@/components/NavBar'
import { UserGreeting } from '@/components/UserGreeting'
import { NotificationList } from '@/components/NotificationList'
import { Skeleton } from '@/components/Skeleton'
export default function DashboardPage() {
return (
<div>
<NavBar /> {/* Shell Estático: Gerado durante o build */}
<Suspense fallback={<Skeleton />}> {/* Limite Dinâmico */}
<UserGreeting />
</Suspense>
<Suspense fallback={<Skeleton />}>
<NotificationList />
</Suspense>
</div>
)
}
(3) Resultados
| Dimensão | SSR Puro | SSG Puro | PPR |
|---|---|---|---|
| Tempo até o Primeiro Byte (TTFB) | 4 segundos | 50 ms | 50 ms |
| Atualidade dos Dados | ✅ Mais recentes | ❌ Snapshot do build | ✅ Tempo real para regiões dinâmicas |
| CPU do Servidor | 85% | < 5% | < 15% |
| Cache CDN | ❌ Não Suportado | ✅ Página Completa | ✅ Shell Estático |
| Complexidade de Implementação | Baixa | Baixa | Baixa (basta adicionar Suspense) |
3. Conceitos e Configuração do PPR
O conceito central do PPR (Partial Prerendering) é que uma página pode conter tanto um shell estático pré-renderizado quanto regiões dinâmicas em streaming. As partes estáticas são geradas durante o build, enquanto as partes dinâmicas são renderizadas sob demanda.
(1) Shell Estático + Limite Dinâmico
graph TB
subgraph "Página PPR"
A[Shell Estático<br/>Layout + Navegação]
B[Limite Suspense 1<br/>Informações do Usuário - Dinâmico]
C[Limite Suspense 2<br/>Lista de Notificações - Dinâmico]
D[Limite Suspense 3<br/>Gráficos em Tempo Real - Dinâmico]
end
A --> B
A --> C
A --> D
style A fill:#d4edda
style B fill:#cce5ff
style C fill:#cce5ff
style D fill:#cce5ff
| Tipo de Componente | Momento da Renderização | Estratégia de Cache | Componentes Típicos |
|---|---|---|---|
| Shell Estático | Durante o build | Cache CDN | Layout, NavBar, Footer, Sidebar, Logo |
| Limite Dinâmico | Na requisição | Não cacheado (ou cache de curta duração) | Saudação do Usuário, Lista de Notificações, gráficos em tempo real, busca |
(2) Ativar PPR
O PPR está desabilitado por padrão no Next.js 16 e deve ser ativado em next.config.ts:
// next.config.ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
experimental: {
ppr: true // Ativar PPR
}
}
export default nextConfig
# Inicie o servidor de desenvolvimento após a instalação
npm run dev
dynamic = 'force-dynamic' ou cache: 'no-store' se beneficiarão automaticamente da otimização PPR por padrão. Você verá o seguinte log durante o build: ✓ PPR enabled for /dashboard
▶ Exemplo: Comparação PPR vs. Não-PPR (Dificuldade: ⭐)
Saída:
O componente React renderiza a UI descrita no navegador.
// app/ppr-compare/page.tsx — Após ativar o PPR, ele entra em vigor automaticamente
import { Suspense } from 'react'
// Seção de Shell Estático (Gerada durante o build)
export default function PprComparePage() {
return (
<div>
<header style={{ background: '#f0f0f0', padding: 16 }}>
<h1>Demo PPR</h1>
<nav><a href="/">Início</a> | <a href="/about">Sobre</a></nav>
</header>
{/* Limite Dinâmico: Obtém novamente a cada requisição */}
<Suspense fallback={<div style={{ padding: 16 }}>Carregando usuário...</div>}>
<RealtimeUser />
</Suspense>
{/* Limite Estático: Renderizado durante o build */}
<footer style={{ borderTop: '1px solid #ddd', padding: 16 }}>
<p>Construído em: {new Date().toISOString()}</p>
</footer>
</div>
)
}
async function RealtimeUser() {
const user = await fetch('https://api.example.com/me', { cache: 'no-store' })
.then(r => r.json())
return <div style={{ padding: 16 }}>Bem-vindo, {user.name}</div>
}
Saída:
Renderiza: Dashboard com sidebar estática + perfil de usuário dinâmico e estatísticas de projeto nos limites Suspense.
4. Suspense: Limites como Fronteiras Dinâmicas
O princípio central do PPR: O conteúdo de cada wrapper <Suspense> é um limite renderizado dinamicamente. Seções não envolvidas por Suspense são shells estáticos pré-renderizados no momento do build.
(1) Regras de Limite
graph LR
A[Componentes da Página] --> B[Conteúdo Estático<br/>Sem Suspense]
A --> C[Limite Suspense]
C --> D[Componentes Filhos Dinâmicos<br/>Renderizados novamente a cada requisição]
A --> E[Outro Suspense]
E --> F[Zonas Dinâmicas Separadas]
| Status de Empacotamento | Comportamento PPR | Exemplo |
|---|---|---|
✅ Envolvido em <Suspense> |
Dinâmico — Renderizado na requisição, conteúdo em tempo real | Informações do usuário, dados de inventário |
❌ Sem <Suspense> |
Estático — Renderizado durante o build, cacheado pelo CDN | Barra de navegação, footer, logo |
(2) Evite Suspense Desnecessário
Se um componente não precisa de dados em tempo real, não o envolva em Suspense — assim, ele se tornará parte do shell estático.
// app/dashboard/page.tsx
export default function DashboardPage() {
return (
<div>
{/* ✅ Estático: A sidebar sempre esteve lá, Não requer atualizações em tempo real */}
<Sidebar />
{/* ✅ Dinâmico: Precisa obter em tempo real */}
<Suspense fallback={<LoadingSpinner />}>
<RealtimeData />
</Suspense>
{/* ❌ Suspense Desnecessário: Este componente não tem dados dinâmicos. */}
<Suspense fallback={<LoadingSpinner />}>
<StaticAboutSection /> {/* Não precisa de wrapper */}
</Suspense>
</div>
)
}
▶ Exemplo: Sequência de Carregamento para Múltiplos Limites Suspense (Dificuldade: ⭐⭐)
Saída:
Renderiza um shell estático imediatamente, com conteúdo dinâmico carregando dentro dos limites Suspense.
Fallback: }>
Texto visível: }> | }>
// app/ppr-timing/page.tsx
import { Suspense } from 'react'
export default function PprTimingPage() {
return (
<div>
<h1>Demo de Temporização PPR</h1>
{/* Shell Estático: Exibido Imediatamente */}
<p>Isto aparece instantaneamente (shell estático)</p>
{/* Limite Dinâmico 1: Exibido após 2 segundos */}
<Suspense fallback={<div>⏳ Carregando seção 1...</div>}>
<DelayedSection label="Seção 1" delay={2000} />
</Suspense>
{/* Limite Dinâmico 2: Exibido após 4 segundos, Independente do Limite 1 */}
<Suspense fallback={<div>⏳ Carregando seção 2...</div>}>
<DelayedSection label="Seção 2" delay={4000} />
</Suspense>
</div>
)
}
async function DelayedSection({ label, delay }: { label: string; delay: number }) {
await new Promise(resolve => setTimeout(resolve, delay))
return <div>✅ {label} carregada após {delay}ms</div>
}
Saída:
Renderiza um shell estático imediatamente, com conteúdo dinâmico carregando dentro dos limites Suspense.
Fallback: ⏳ Carregando seção 1...
Texto visível: Demo de Temporização PPR | Isto aparece instantaneamente (shell estático) | ⏳ Carregando seção 1... | }>
5. Comparação de Performance: PPR vs. ISR vs. SSR
Cada um dos três modos de renderização tem seus próprios casos de uso, e o PPR preenche a lacuna para cenários "parcialmente estático + parcialmente dinâmico".
| Dimensão | SSR | ISR | PPR |
|---|---|---|---|
| Momento da Renderização | A Cada Requisição | Build + Sob Demanda em Segundo Plano | Build (Shell Estático) + Requisição (Conteúdo Dinâmico) |
| Política de Cache | Não Cacheado pelo CDN | Cache CDN de Página Completa | Shell Estático CDN + Áreas Dinâmicas Não Cacheadas |
| Tempo Real | ✅ Mais recente | ⚠️ Atraso máximo = revalidate | ✅ Tempo real para regiões dinâmicas |
| Carga do Servidor | Alta | Baixa | Baixa (renderiza apenas partes dinâmicas) |
| Casos de Uso | Páginas Personalizadas e de Autenticação | Sites de Conteúdo e Blogs | Dashboards e Páginas Híbridas |
| Tempo até a Primeira Visualização | Lento (esperando renderização no servidor) | Rápido (cacheado) | Rápido (shell estático, instantâneo) |
(1) Recomendações de Seleção
graph TB
A[Esta página requer?] --> B{Dados em Tempo Real?}
B -->|A página inteira é atualizada em tempo real| C[SSR]
B -->|Tempo real em algumas áreas| D[PPR]
B -->|Sem dados em tempo real| E{Frequência de Atualização?}
E -->|Frequente| F[ISR]
E -->|Praticamente não muda| G[SSG]
style D fill:#d4edda
▶ Exemplo: Design de Componentes de Dashboard PPR (Dificuldade: ⭐⭐⭐)
Saída:
Diagrama da estratégia de renderização: shell estático + limites Suspense dinâmicos.
// app/dashboard-ppr/page.tsx — Design Prático PPR
import { Suspense } from 'react'
// ======== Conjunto de Shell Estático ========
function DashboardHeader() {
return (
<header style={{ background: '#1a1a2e', color: 'white', padding: '16px 24px' }}>
<h1 style={{ margin: 0 }}>TaskFlow Dashboard</h1>
</header>
)
}
function Sidebar() {
return (
<nav style={{ width: 240, background: '#f5f5f5', padding: 16, minHeight: 'calc(100vh - 64px)' }}>
<ul style={{ listStyle: 'none', padding: 0 }}>
<li><a href="/">🏠 Início</a></li>
<li><a href="/projects">📁 Projetos</a></li>
<li><a href="/tasks">✅ Tarefas</a></li>
<li><a href="/analytics">📊 Analytics</a></li>
<li><a href="/settings">⚙️ Configurações</a></li>
</ul>
</nav>
)
}
// ======== Componente de Limite Dinâmico ========
async function UserProfile() {
const user = await fetch('https://api.example.com/me', { cache: 'no-store' }).then(r => r.json())
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<img src={user.avatar} alt="" style={{ borderRadius: '50%', width: 40, height: 40 }} />
<div>
<strong>{user.name}</strong>
<p style={{ margin: 0, fontSize: 12, color: '#666' }}>{user.role}</p>
</div>
</div>
)
}
async function ProjectStats() {
const stats = await fetch('https://api.example.com/stats', { next: { revalidate: 60 } }).then(r => r.json())
return (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 16 }}>
<StatCard label="Projetos Ativos" value={stats.activeProjects} color="#4caf50" />
<StatCard label="Tarefas Pendentes" value={stats.pendingTasks} color="#ff9800" />
<StatCard label="Concluídos" value={stats.completed} color="#2196f3" />
</div>
)
}
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
return (
<div style={{ border: `1px solid ${color}`, borderRadius: 8, padding: 16, textAlign: 'center' }}>
<p style={{ fontSize: 28, fontWeight: 'bold', color, margin: 0 }}>{value}</p>
<p style={{ margin: 0, color: '#666', fontSize: 14 }}>{label}</p>
</div>
)
}
export default function DashboardPprPage() {
return (
<div style={{ display: 'flex' }}>
<Sidebar />
<main style={{ flex: 1, padding: 24 }}>
<DashboardHeader />
<Suspense fallback={<div>Carregando perfil...</div>}>
<UserProfile />
</Suspense>
<div style={{ height: 24 }} />
<Suspense fallback={<div>Carregando estatísticas...</div>}>
<ProjectStats />
</Suspense>
</main>
</div>
)
}
Saída:
Obtém dados no lado do servidor e renderiza o resultado na página.
Conteúdo visível: TaskFlow Dashboard
6. Exemplo Completo: Dashboard de E-commerce PPR
// app/ppr-ecommerce/page.tsx — Dashboard de E-commerce PPR
import { Suspense } from 'react'
import { cookies } from 'next/headers'
// ======== Shell Estático ========
function StoreHeader() {
return (
<header style={{ background: '#2c3e50', color: 'white', padding: '12px 24px', display: 'flex', justifyContent: 'space-between' }}>
<strong>Store Dashboard</strong>
<span>Construído: {new Date().toISOString().split('T')[0]}</span>
</header>
)
}
function Navigation() {
return (
<nav style={{ background: '#34495e', padding: '8px 24px', display: 'flex', gap: 24, color: 'white' }}>
<a href="/ppr-ecommerce" style={{ color: 'white' }}>Visão Geral</a>
<a href="/ppr-ecommerce/orders" style={{ color: 'white' }}>Pedidos</a>
<a href="/ppr-ecommerce/products" style={{ color: 'white' }}>Produtos</a>
</nav>
)
}
// ======== Componentes Dinâmicos ========
async function LiveOrderFeed() {
const orders = await fetch('https://api.example.com/orders/recent', {
cache: 'no-store'
}).then(r => r.json())
return (
<div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
<h2>Pedidos ao Vivo ({orders.length})</h2>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead><tr><th>Pedido</th><th>Cliente</th><th>Status</th><th>Total</th></tr></thead>
<tbody>
{orders.map((o: any) => (
<tr key={o.id}>
<td>#{o.id}</td>
<td>{o.customer}</td>
<td><StatusBadge status={o.status} /></td>
<td>${o.total}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const colors: Record<string, string> = {
pending: '#ff9800', shipped: '#2196f3', delivered: '#4caf50', cancelled: '#f44336'
}
return <span style={{ background: colors[status] ?? '#ccc', color: 'white', padding: '2px 8px', borderRadius: 12, fontSize: 12 }}>{status}</span>
}
async function RevenueWidget() {
const revenue = await fetch('https://api.example.com/revenue/today', {
next: { revalidate: 300 }
}).then(r => r.json())
return (
<div style={{ background: 'linear-gradient(135deg, #667eea, #764ba2)', color: 'white', borderRadius: 8, padding: 24 }}>
<h2 style={{ margin: 0, fontSize: 14, opacity: 0.8 }}>Receita de Hoje</h2>
<p style={{ fontSize: 36, fontWeight: 'bold', margin: '8px 0' }}>${revenue.total}</p>
<p style={{ margin: 0, fontSize: 12, opacity: 0.8 }}>↑ {revenue.growth}% vs ontem</p>
</div>
)
}
async function TopProducts() {
const products = await fetch('https://api.example.com/products/top', {
next: { tags: ['top-products'] }
}).then(r => r.json())
return (
<div style={{ border: '1px solid #ddd', borderRadius: 8, padding: 16 }}>
<h2>Produtos Mais Vendidos</h2>
<ol>{products.slice(0, 5).map((p: any) => (
<li key={p.id}>{p.name} — {p.sold} vendidos</li>
))}</ol>
</div>
)
}
// ======== Página (Mistura PPR) ========
export default function PprEcommercePage() {
return (
<div>
<StoreHeader />
<Navigation />
<main style={{ padding: 24, display: 'grid', gap: 24, gridTemplateColumns: '2fr 1fr' }}>
<div>
<Suspense fallback={<div>Carregando pedidos...</div>}>
<LiveOrderFeed />
</Suspense>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
<Suspense fallback={<div>Carregando receita...</div>}>
<RevenueWidget />
</Suspense>
<Suspense fallback={<div>Carregando produtos...</div>}>
<TopProducts />
</Suspense>
</div>
</main>
</div>
)
}
▶ Exemplo: Verificação de Artefatos de Build (Dificuldade: ⭐)
# Visualize a saída do shell estático após o build
npm run build
# Em .next/server/app/ppr-ecommerce, role para baixo para visualizar
ls .next/server/app/ppr-ecommerce/
Saída:
page.html ← HTML do Shell Estático (Barra de Navegação, Layout)
page.rsc ← RSC Payload (Seção Estática)
page_stream.html ← Ponto de Injeção Parcial de Fluxo
▶ Exemplo: Validação Shell Estático PPR + Dados Dinâmicos (Dificuldade: ⭐⭐)
Saída:
A página renderiza conforme descrito acima, com a UI atualizando de acordo com o comportamento descrito.
// app/ppr-verify/page.tsx
import { Suspense } from 'react'
function StaticHeader() {
return (
<header style={{ borderBottom: '2px solid #333', padding: 16, marginBottom: 16 }}>
<h1>Página de Verificação PPR</h1>
<p>Timestamp do build: {new Date().toISOString()}</p>
<nav><a href="/">Início</a> | <a href="/ppr-verify">Atualizar</a></nav>
</header>
)
}
async function DynamicContent() {
const res = await fetch('http://worldtimeapi.org/api/timezone/Etc/UTC', {
cache: 'no-store'
}).then(r => r.json())
return (
<div style={{ background: '#e3f2fd', padding: 16, borderRadius: 8 }}>
<h2>Hora do Servidor ao Vivo</h2>
<p style={{ fontSize: 24 }}>{res.datetime}</p>
</div>
)
}
export default function PprVerifyPage() {
return (
<div>
<StaticHeader />
<Suspense fallback={<div style={{ padding: 16 }}>⏳ Carregando hora ao vivo...</div>}>
<DynamicContent />
</Suspense>
<footer style={{ marginTop: 32, color: '#666' }}>
<p>O cabeçalho é shell estático (cacheado). O conteúdo dinâmico é atualizado por requisição.</p>
</footer>
</div>
)
}
Saída:
Obtém dados no lado do servidor e renderiza o resultado na página.
Conteúdo visível: Página de Verificação PPR
❓ Perguntas Frequentes
P: Qual é a diferença entre PPR e Streaming SSR? R: Com Streaming SSR, a renderização ocorre apenas quando uma requisição é feita, e cada limite Suspense é "dinâmico". O shell estático do PPR é gerado no momento do build — os usuários veem HTML cacheado no CDN, não renderização em tempo real no servidor. PPR = shell estático + regiões dinâmicas em Streaming.
P: Todas as páginas migrarão para PPR após ativá-lo? R: Sim, por padrão, todas as rotas sem
dynamic = 'force-dynamic'usarão PPR. No entanto, apenas páginas contendo o limite<Suspense>terão o efeito "shell estático + região dinâmica". Páginas sem Suspense permanecerão puramente estáticas (comportamento SSG).
P: As regiões dinâmicas do PPR podem ser aninhadas? R: Sim. Limites Suspense podem ser aninhados — o Suspense interno depende do externo. No entanto, recomenda-se manter uma estrutura plana (2–5 limites Suspense por página), pois aninhamento excessivo pode levar a uma ordem de carregamento complexa.
P: Como os dados em um shell estático PPR são atualizados? R: Os dados em um shell estático são congelados no momento do build. Se você renderizar um nome de usuário no layout, é um snapshot do momento do build. Solução: Coloque dados específicos do usuário dentro de um limite Suspense dinâmico e reserve o shell estático para conteúdo que nunca muda (como logos, links de navegação e estrutura de layout).
P: O PPR pode coexistir com outros modos de renderização? R: Sim. Dentro da mesma aplicação, você pode ter páginas PPR (Dashboard), páginas SSG (Marketing), páginas ISR (Blog) e páginas SSR (Admin). Os modos de renderização são configurados no nível da rota — cada segmento de rota é configurado independentemente.
P: Como o PPR se comporta no modo de desenvolvimento? R: O PPR ainda funciona no modo de desenvolvimento, mas como cada requisição é reconstruída do zero, os benefícios do shell estático (cache CDN) não são perceptíveis localmente. Você só experimentará verdadeiramente o ganho de performance do PPR após um build de produção. Execute
npm run build && npm run startpara testar o modo de produção.
📖 Resumo
- PPR divide uma página em um shell estático (gerado no build) e um limite dinâmico (renderizado no momento da requisição)
- O limite
<Suspense>é um limite dinâmico — conteúdo sem Suspense é um shell estático - Configure
experimental.ppr = trueemnext.config.tspara ativar o PPR - PPR carrega a primeira tela 10 a 100 vezes mais rápido que SSR (shell estático cacheado via CDN), enquanto as seções dinâmicas permanecem em tempo real
- Shells estáticos são adequados para: barras de navegação, sidebars, footers, logos e texto estático
- Limites dinâmicos são adequados para: informações do usuário, notificações, gráficos em tempo real, busca e conteúdo personalizado
- PPR é o melhor compromisso entre SSG e SSR e é adequado para a maioria das páginas com conteúdo misto
📝 Exercícios
-
Exercício Básico (⭐): Ative o PPR em
next.config.ts, crie umapp/ppr-basic/page.tsxque contenha uma seção<header>estática e uma seção<Suspense>dinâmica (chame a API para obter a hora atual), e verifique se a saída do build inclui HTML estático. -
Exercício Avançado (⭐⭐): Crie um
app/ppr-dashboard/page.tsxque contenha pelo menos 3 limites Suspense (informações do usuário, lista de notificações, estatísticas em tempo real), cada um com seu próprio fallback. Garanta que a barra de navegação e a sidebar sejam wrappers estáticos. Após o build, verifique sepage.htmlinclui a barra de navegação mas não contém nenhum conteúdo dinâmico. -
Desafio (⭐⭐⭐): Construa um dashboard PPR de múltiplas páginas —
app/ppr-portal/— com três subpáginas: Visão Geral, Pedidos e Analytics. Essas páginas compartilham um layout estático (navegação superior + sidebar), e cada página contém 2–4 limites Suspense dinâmicos. Adicione Server Actions para permitir que os usuários enviem dados dentro das áreas dinâmicas, disparandorevalidateTag()para atualizar as áreas dinâmicas correspondentes.