Back

Next.js Partial Prerendering (PPR) a fundo: como funciona, quando usar e por que muda tudo

Todo dev Next.js já enfrentou a mesma escolha impossível: estático ou dinâmico? Pré-renderizar a página no build time pra ter velocidade (SSG), ou renderizar a cada request pra ter dados frescos (SSR)? Escolhe um. Não dá pra ter os dois.

Até agora. Partial Prerendering (PPR) é a mudança arquitetural mais significativa no Next.js desde o App Router. Ele permite servir um shell estático instantaneamente (headers, navegação, layout, conteúdo above-the-fold) enquanto faz streaming das partes dinâmicas (dados por usuário, preços em tempo real, recomendações personalizadas) na mesma resposta HTTP. Sem fetches no client. Sem layout shift. Uma request, uma resposta. Velocidade estática com frescor dinâmico.

Não é incremental. É o fim da árvore de decisão SSG vs. SSR. Bora ver exatamente como funciona, como implementar e onde dá problema.

O problema que o PPR resolve

Antes do PPR, você tinha quatro estratégias de renderização no Next.js, e cada uma vinha com um tradeoff doloroso:

EstratégiaVelocidadeFrescorPersonalização
SSG⚡ Instantâneo❌ Stale até rebuild❌ Igual pra todo mundo
ISR⚡ Rápido⚠️ Stale dentro do window de revalidation❌ Igual pra todo mundo
SSR🐌 TTFB lento✅ Sempre fresco✅ Por usuário
CSR🐌 FCP lento✅ Sempre fresco✅ Por usuário

Vamos pro problema real. Pensa numa página de produto de e-commerce:

  • Título, descrição, imagens do produto raramente mudam → devem ser estáticos
  • Preço, estoque, reviews mudam o tempo todo → precisam ser dinâmicos
  • Recomendações personalizadas são por usuário → exigem personalização

Com SSG, seus preços ficam velhos. Com SSR, o TTFB é péssimo porque o servidor tá esperando preço, estoque E recomendações antes de mandar um único byte. Com CSR, o usuário fica olhando skeleton enquanto três API calls resolvem.

PPR elimina toda essa matriz. Uma página, uma request: as partes estáticas chegam instantaneamente, as dinâmicas vão sendo streameadas conforme resolvem.

Como o PPR funciona por baixo dos panos

PPR não é mágica, mas a arquitetura é esperta.

Passo 1: Build time — Gerar o shell estático

No next build, o Next.js renderiza sua página como faria pro SSG. Mas quando encontra um <Suspense> boundary envolvendo um componente dinâmico, ele para. Não tenta resolver aquele componente. Em vez disso:

  1. Renderiza tudo que tá fora dos <Suspense> boundaries num shell HTML estático
  2. Injeta um fallback placeholder onde cada <Suspense> existe
  3. Armazena esse shell no CDN/edge, pronto pra servir instantaneamente
// app/product/[id]/page.tsx import { Suspense } from "react"; import { ProductHeader } from "./ProductHeader"; import { PricingSection } from "./PricingSection"; import { Recommendations } from "./Recommendations"; export default async function ProductPage({ params, }: { params: { id: string }; }) { const product = await getProduct(params.id); // Estático — fetched no build time return ( <main> {/* Estático: renderizado no build time, servido do CDN */} <ProductHeader product={product} /> {/* Dinâmico: streameado no request time */} <Suspense fallback={<PricingSkeleton />}> <PricingSection productId={params.id} /> </Suspense> {/* Dinâmico: streameado no request time */} <Suspense fallback={<RecommendationsSkeleton />}> <Recommendations productId={params.id} /> </Suspense> </main> ); }

Passo 2: Request time — Servir shell + streamear partes dinâmicas

Quando um usuário acessa /product/123:

  1. O CDN serve o shell estático pré-construído instantaneamente
  2. Simultaneamente, o servidor começa a executar os componentes dinâmicos dentro dos <Suspense>
  3. Conforme cada componente dinâmico resolve, seu HTML é streameado na resposta, substituindo o fallback

Tudo isso acontece numa única request HTTP. O browser começa a pintar o shell estático enquanto as partes dinâmicas ainda tão sendo processadas no servidor.

Passo 3: O browser recebe uma resposta progressiva

Tempo (ms)   O que o usuário vê
──────────────────────────────────────────
0            Request enviada
50           Shell estático chega → Página tá visível!
120          Dados de preço chegam → Skeleton vira preço real
200          Recomendações chegam → Skeleton vira cards

Compara com SSR tradicional:

Tempo (ms)   O que o usuário vê
──────────────────────────────────────────
0            Request enviada
350          NADA — servidor esperando TODOS os dados
350          Página inteira chega de uma vez

Ativando PPR: passo a passo

PPR foi introduzido como experimental no Next.js 14, refinado no Next.js 15, e lançado como estável no Next.js 16 (outubro de 2025) via Cache Components. No Next.js 16, a flag experimental.ppr foi removida e substituída pela configuração cacheComponents.

1. Atualizar next.config.ts

// next.config.ts — Next.js 16+ const nextConfig = { cacheComponents: true, }; export default nextConfig;

Nota: Se você ainda tá no Next.js 14 ou 15, use a flag experimental anterior:

const nextConfig = { experimental: { ppr: true } };

No Next.js 16, todo código é dinâmico por padrão. Você opta partes da página pro cache estático usando a diretiva "use cache" ou estruturando componentes com Suspense boundaries. É o inverso de versões anteriores onde as páginas eram estáticas por padrão.

2. Estruturar sua página com Suspense boundaries

A sacada-chave: Suspense boundaries são a linha divisória entre estático e dinâmico. Tudo que tá fora de um <Suspense> é pré-renderizado no build time. Tudo que tá dentro é deferido pro request time.

// app/dashboard/page.tsx import { Suspense } from "react"; import { DashboardLayout } from "@/components/DashboardLayout"; import { UserProfile } from "@/components/UserProfile"; import { RecentActivity } from "@/components/RecentActivity"; import { Analytics } from "@/components/Analytics"; export default function DashboardPage() { return ( <DashboardLayout> <h1>Dashboard</h1> <div className="grid grid-cols-3 gap-6"> <Suspense fallback={<ProfileSkeleton />}> <UserProfile /> </Suspense> <Suspense fallback={<ActivitySkeleton />}> <RecentActivity /> </Suspense> <Suspense fallback={<AnalyticsSkeleton />}> <Analytics /> </Suspense> </div> </DashboardLayout> ); }

3. Tornar componentes dinâmicos realmente dinâmicos

Um componente se torna dinâmico quando acessa dados de request-time:

import { cookies } from "next/headers"; export async function UserProfile() { const session = (await cookies()).get("session"); const user = await getUser(session?.value); return ( <div className="profile-card"> <img src={user.avatar} alt={user.name} /> <h2>{user.name}</h2> <p>{user.email}</p> </div> ); }

O que torna um componente dinâmico:

  • cookies() — ler cookies da request
  • headers() — ler headers da request
  • searchParams — acessar query parameters
  • connection() — opt-in pra renderização dinâmica
  • fetch() sem cache — fetch(url, { cache: "no-store" })

O que se mantém estático:

  • Componentes sem dependências de dados dinâmicos
  • Componentes usando só fetch() com cache
  • Componentes com generateStaticParams()

Por que o PPR é fundamentalmente diferente

PPR não é só "SSR com streaming" (isso já existia com loading.tsx). A diferença crítica é o shell estático:

Sem PPR (Streaming SSR padrão)

Request → Servidor computa TUDO → Streameia progressivamente
          └── Ainda espera o layout mais externo resolver
          └── TTFB depende do componente pai mais lento

Com PPR

Request → CDN serve shell estático pré-construído INSTANTANEAMENTE
          └── Servidor só computa componentes dentro de Suspense
          └── TTFB = latência do CDN (~20-50ms do edge)

O shell estático é servido do CDN edge, não do seu servidor de origem. Seu TTFB é determinado pela proximidade do usuário ao edge node mais próximo, não pela velocidade da query no banco.

Performance real: antes e depois do PPR

Métricas concretas de uma página de produto de e-commerce em produção.

MétricaSó SSRPPRMelhoria
TTFB380ms32ms11.9x mais rápido
FCP420ms65ms6.5x mais rápido
LCP680ms65ms10.5x mais rápido
CLS0.020.00Eliminado

Padrões e anti-padrões

✅ Padrão: Suspense boundaries granulares

Envolva cada dependência de dados independente no seu próprio Suspense boundary:

// ✅ Bom: streaming independente <Suspense fallback={<PriceSkeleton />}> <Price productId={id} /> </Suspense> <Suspense fallback={<ReviewsSkeleton />}> <Reviews productId={id} /> </Suspense>

❌ Anti-padrão: Suspense boundary gigante

Não envolve tudo num Suspense boundary só. Derrota o propósito do PPR fazendo a página inteira dinâmica.

❌ Anti-padrão: APIs dinâmicas no Layout

Se o root layout lê cookies ou headers, a página inteira vira dinâmica:

// ❌ Isso torna TODA a página dinâmica import { cookies } from "next/headers"; export default async function RootLayout({ children }) { const theme = (await cookies()).get("theme"); return <html data-theme={theme?.value}>{children}</html>; }

Move o acesso a dados dinâmicos pra componentes dentro de Suspense:

// ✅ Layout estático, partes dinâmicas separadas import { Suspense } from "react"; export default function RootLayout({ children }) { return ( <html> <body> <Suspense fallback={<NavSkeleton />}> <ThemeProvider /> </Suspense> {children} </body> </html> ); }

PPR vs. outras estratégias: framework de decisão

Use PPR quando:

  • Páginas com conteúdo estático e dinâmico misturado — Páginas de produto, dashboards
  • TTFB importa — E-commerce, sites de conteúdo, páginas SEO-critical
  • Quer personalização sem sacrificar velocidade — Recomendações, A/B tests

Mantenha SSG puro quando:

  • A página inteira é estática — Blogs, documentação, marketing
  • Sem necessidade de personalização

Mantenha SSR quando:

  • Cada byte depende da request — Fluxos de autenticação, admin panels
  • Consistência de dados é crítica

Migração de SSR pra PPR

Passo 1: Identificar boundaries estáticos/dinâmicos

grep -rn "cookies()\|headers()\|searchParams\|connection()\|no-store" \ --include="*.tsx" --include="*.ts" ./app

Passo 2: Envolver componentes dinâmicos em Suspense

// Antes: tudo dinâmico export default async function Page() { const user = await getUser(); const posts = await getPosts(); return ( <div> <ProfileCard user={user} /> <PostList posts={posts} /> </div> ); } // Depois: shell estático + streaming dinâmico export default function Page() { return ( <div> <Suspense fallback={<ProfileSkeleton />}> <ProfileCard /> </Suspense> <PostList /> {/* Estático */} </div> ); }

Passo 3: Ativar PPR e buildar

// next.config.ts — Next.js 16+ const nextConfig = { cacheComponents: true, }; // Next.js 14/15 (legacy): // const nextConfig = { experimental: { ppr: true } };
next build

No output do build, páginas PPR são marcadas com ◐:

◐ /product/[id]    Partial Prerendering
○ /about           Static
● /blog/[slug]     SSG

Debugging: problemas comuns

Problema 1: Página inteira vira dinâmica

Sintoma: Build output mostra a página como fully dynamic (λ) em vez de partial (◐).

Causa: Uma API dinâmica tá sendo chamada fora de um Suspense boundary.

Fix: Procure cookies(), headers(), connection() e mova pra dentro de componentes em Suspense.

Problema 2: Layout shift mesmo com skeletons

Fix: Garanta que os skeletons tenham min-height ou aspect-ratio explícitos:

<div style={{ contain: "layout", minHeight: "200px" }}> <Suspense fallback={<Skeleton />}> <DynamicComponent /> </Suspense> </div>

O quadro geral: por que PPR importa além da performance

PPR não é só uma otimização de performance. Representa uma mudança fundamental em como pensamos a arquitetura web:

  1. O fim das decisões de renderização full-page: Você não escolhe SSG ou SSR. Você escolhe SSG e SSR, por componente, na mesma página.

  2. Edge-First por padrão: O shell estático mora no CDN edge. Seu servidor de origem só lida com as partes dinâmicas.

  3. Progressive Enhancement embutido: Usuários com conexões lentas veem o shell estático imediatamente. Conteúdo dinâmico chega conforme a banda permite.

  4. Cache simplificado: Partes estáticas são trivialmente cacheáveis pra sempre (baseado em content-hash). Partes dinâmicas nunca são cacheadas.

  5. Alinhado com a direção do React: PPR é a convergência natural de React Server Components, Suspense e streaming.

Conclusão

Partial Prerendering elimina o tradeoff mais antigo e doloroso do desenvolvimento web: estático vs. dinâmico. Usando React Suspense como boundary entre conteúdo pré-renderizado e computação em request-time, o PPR entrega TTFB na velocidade do CDN enquanto continua servindo dados personalizados em tempo real.

O modelo mental é simples: tudo fora do <Suspense> é estático. Tudo dentro é dinâmico. O framework cuida do resto.

Com Cache Components do Next.js 16, PPR graduou de experimental pra estável. Pra maioria das aplicações Next.js, PPR deveria ser a estratégia de renderização padrão daqui pra frente.

Comece com cacheComponents: true no seu next.config.ts. Builde. Se ver o indicador ◐, você acabou de tornar suas páginas mais rápidas do que nunca.

Next.jsReactPPRperformanceSSRSSGweb developmentServer ComponentsSuspense

Explore ferramentas relacionadas

Experimente estas ferramentas gratuitas do Pockit