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



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:

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

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

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

TS
// next.config.ts
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  experimental: {
    ppr: true  // Ativar PPR
  }
}

export default nextConfig
BASH
# Inicie o servidor de desenvolvimento após a instalação
npm run dev
⚠️ Nota: PPR requer Next.js 16.2 ou superior. Uma vez ativado, todas as páginas que não contêm 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:

TEXT 📖 Somente leitura
O componente React renderiza a UI descrita no navegador.
TSX
// 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:

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

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

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

TEXT 📖 Somente leitura
Renderiza um shell estático imediatamente, com conteúdo dinâmico carregando dentro dos limites Suspense.
Fallback: }>
Texto visível: }> | }>
TSX
// 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:

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

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

TEXT 📖 Somente leitura
Diagrama da estratégia de renderização: shell estático + limites Suspense dinâmicos.
TSX
// 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:

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

TSX
// 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: ⭐)

BASH
# 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:

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

TEXT 📖 Somente leitura
A página renderiza conforme descrito acima, com a UI atualizando de acordo com o comportamento descrito.
TSX
// 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:

TEXT 📖 Somente leitura
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 start para testar o modo de produção.


📖 Resumo


📝 Exercícios

  1. Exercício Básico (⭐): Ative o PPR em next.config.ts, crie um app/ppr-basic/page.tsx que 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.

  2. Exercício Avançado (⭐⭐): Crie um app/ppr-dashboard/page.tsx que 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 se page.html inclui a barra de navegação mas não contém nenhum conteúdo dinâmico.

  3. 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, disparando revalidateTag() para atualizar as áreas dinâmicas correspondentes.

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%