El Problema que PPR Resuelve

Considérese una página típica de producto de e-commerce:

┌──────────────────────────────────────┐
│ Header (con avatar de usuario) │ ← Dinámico (necesita auth)
├──────────────────────────────────────┤
│ Galería de Imágenes del Producto │ ← Estático (cambia mensualmente)
│ Título y Descripción del Producto │ ← Estático
│ Reviews del Producto (234 reviews) │ ← Estático (cacheado)
├──────────────────────────────────────┤
│ Precio: $49.99 │ ← Dinámico (cambia cada hora)
│ Stock: quedan 3 │ ← Dinámico (tiempo real)
│ [Agregar al Carrito] │ ← Dinámico (necesita sesión)
├──────────────────────────────────────┤
│ Productos Relacionados │ ← Estático
│ Footer │ ← Estático
└──────────────────────────────────────┘

PPR introduce una opción nueva: servir las partes estáticas instantáneamente desde el CDN, y streamear las partes dinámicas desde el origin en la misma respuesta.

Cómo Funciona PPR

PPR en Next.js usa boundaries de React Suspense para definir la separación entre contenido estático y dinámico:

// app/products/[slug]/page.jsx
import { Suspense } from 'react';

export default async function ProductPage({ params }) {
 const product = await getProduct(params.slug); // datos estáticos

 return (
 <div className="product-page">
 {/* Shell estático, prerenderizado en build time */}
 <ProductDetails product={product} />
 <ProductReviews productId={product.id} />

 {/* Agujeros dinámicos, streameados en request time */}
 <Suspense fallback={<PriceSkeleton />}>
 <PricingWidget productId={product.id} />
 </Suspense>

 <Suspense fallback={<CartSkeleton />}>
 <CartButton productId={product.id} />
 </Suspense>
 </div>
 );
}

Cuando un usuario pide esta página:

Timeline:

0ms: CDN recibe request
5ms: CDN sirve shell estático (HTML prerenderizado)
 Usuario ve: imágenes, descripción, reviews, skeletons de carga
5ms: CDN simultáneamente reenvía request al origin para agujeros dinámicos
200ms: Origin envía contenido dinámico (precio, stock, botón de carrito)
 Usuario ve: página completa con precio y stock en tiempo real

El TTFB es 5ms (el shell estático desde CDN), no 200ms (la respuesta SSR completa).

Habilitando PPR

// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
 experimental: {
 ppr: true,
 },
};

module.exports = nextConfig;

O por ruta:

// app/products/[slug]/page.jsx
export const experimental_ppr = true;

¿Cómo Sabe Next.js Qué es Dinámico?

Detecta uso de APIs dinámicas:

// Estos hacen un componente dinámico:
cookies() // Lee cookies del request
headers() // Lee headers del request
searchParams // Prop de page desde URL search params
unstable_noStore() // Opt-out explícito de caching

// Estos siguen siendo estáticos (cacheados en build):
fetch('https://api.example.com/product/123') // Cacheado por default

Mediciones Reales de TTFB

Estas mediciones son de una página de listado de productos con 20 productos, cada uno con precio y stock dinámicos:

Estrategia de Rendering TTFB (p50) TTFB (p99) Carga Total
──────────────────────────────────────────────────────────────────
SSR (sin cache) 380ms 1200ms 920ms
ISR (revalidación 60s) 45ms 120ms 520ms
ISR + client fetch 45ms 120ms 780ms*
PPR 42ms 95ms 580ms
──────────────────────────────────────────────────────────────────
* ISR + client fetch tiene layout shift cuando cargan los precios

La ganancia real es la experiencia de usuario. Con ISR + client fetch, la página carga, el usuario ve precios placeholder, después 300ms más tarde los precios reales aparecen con layout shift. Con PPR, el skeleton aparece en el shell estático y se reemplaza seamlessly por el contenido streameado, sin layout shift, sin flash de contenido incorrecto.

Migración desde ISR

Antes (ISR)

export const revalidate = 60;

export default async function ProductPage({ params }) {
 const product = await getProduct(params.slug);
 const price = await getPrice(product.id); // Stale por hasta 60s
 const stock = await getStock(product.id); // Stale por hasta 60s

 return (
 <div>
 <ProductDetails product={product} />
 <div className="price">{price}</div>
 <div className="stock">{stock} restantes</div>
 <AddToCartButton productId={product.id} />
 </div>
 );
}

Después (PPR)

export const experimental_ppr = true;

export default async function ProductPage({ params }) {
 const product = await getProduct(params.slug);

 return (
 <div>
 <ProductDetails product={product} />

 <Suspense fallback={<div className="price-skeleton" />}>
 <DynamicPricing productId={product.id} />
 </Suspense>

 <Suspense fallback={<div className="stock-skeleton" />}>
 <DynamicStock productId={product.id} />
 </Suspense>

 <Suspense fallback={<div className="cart-skeleton" />}>
 <AddToCartButton productId={product.id} />
 </Suspense>
 </div>
 );
}

import { unstable_noStore } from 'next/cache';

async function DynamicPricing({ productId }) {
 unstable_noStore();
 const price = await getPrice(productId);
 return <div className="price">{formatPrice(price)}</div>;
}

async function DynamicStock({ productId }) {
 unstable_noStore();
 const stock = await getStock(productId);
 return (
 <div className={`stock ${stock < 5 ? 'low-stock' : ''}`}>
 {stock > 0 ? `${stock} restantes` : 'Sin stock'}
 </div>
 );
}

Errores Comunes

Datos dinámicos en layouts se propagan. Si tu root layout lee cookies() para preferencia de tema, todo el sitio se vuelve dinámico. Mové la lectura de cookies a un boundary de Suspense dentro del layout.

El diseño de skeletons importa. El shell estático es lo que los usuarios ven los primeros 100-300ms. Si tus skeletons no matchean las dimensiones del layout final, teners Cumulative Layout Shift.

.price-skeleton {
 height: 32px;
 width: 120px;
 background: var(--skeleton-bg);
 border-radius: 4px;
 animation: pulse 1.5s ease-in-out infinite;
}

No todo necesita ser dinámico. Un error común es wrappear demasiadas cosas en Suspense. La mayoría de los datos cambian infrecuentemente y deberían quedarse en el shell estático con revalidación estilo ISR.

Cuándo NO Usar PPR

  • Páginas completamente estáticas (blog, docs): Utilizar SSG regular.
  • Páginas completamente dinámicas (dashboards, paneles admin): Utilizar SSR regular.
  • Páginas con contenido altamente personalizado: Si el 80%+ de la página es user-specific, el shell estático es demasiado fino para proveer mejora significativa de TTFB.

PPR brilla cuando teners páginas mayormente estáticas con algunos widgets dinámicos, e-commerce, sitios de noticias, páginas de marketing con stats en tiempo real, catálogos de productos con precios en vivo. Eso cubre un porcentaje sorprendentemente grande de aplicaciones web en producción.

El debate ISR vs SSR terminó. PPR no es un compromiso entre los dos, es un modelo de rendering genuinamente nuevo que te da el TTFB de generación estática con la frescura de rendering server-side. La clave es pensar la página como un shell estático con agujeros dinámicos en vez de un render monolítico. Una vez que ese modelo mental hace click, la implementación fluye naturalmente.