<!-- Canonical: https://lucioduran.com/es/blog/next-js-15-ppr-partial-prerendering
Language: es
Content-SHA256: a3b7461cceed594ce54fcf8e45d1d3aa5e643993c4b86230feccd752ccc310ce
-->

Frontend

# Next.js 15 PPR: Partial Prerendering, Static Shells, y el Fin del Debate ISR vs SSR

2025-12-175 min lectura

Lucio Durán
Engineering Manager & AI Solutions Architect

También disponible en: [English](<https://lucioduran.com/blog/next-js-15-ppr-partial-prerendering>)

## 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](<https://nextjs.org/>) usa boundaries de React Suspense para definir la separación entre contenido estático y dinámico:

```jsx
// 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

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

module.exports = nextConfig;
```

O por ruta:

```javascript
// 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:

```javascript
// 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)

```javascript
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)

```javascript
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.

```css
.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.

## Preguntas frecuentes

### ¿Cuál es la diferencia entre PPR e ISR?

ISR (Incremental Static Regeneration) sirve una página completamente cacheada y revalida en background después de un timeout. La página entera es stale o fresh. PPR sirve un shell estático inmediatamente (desde el CDN edge) y streamea contenido dinámico en la misma respuesta. Diferentes partes de la página pueden tener diferentes garantías de frescura.

### ¿PPR funciona con el Pages Router?

No. PPR requiere el App Router porque depende de React Server Components y boundaries de Suspense para definir el split estático/dinámico. Si se está en el Pages Router, ISR con getStaticProps sigue siendo tu mejor opción para páginas híbridas estáticas/dinámicas.

### ¿Cuánto mejora el TTFB con PPR?

En las mediciones, PPR mejora el TTFB un 60-80% comparado con SSR completo porque el shell estático se sirve desde el CDN edge sin tocar el servidor de origen. La mejora en tiempo total de carga es menor (20-40%) porque el contenido dinámico todavía necesita computarse y streamearse. La mejora percibida es aún mayor porque los usuarios ven el shell estático inmediatamente.

## Tips

1. **Tip:** Wrappeá solo las partes verdaderamente dinámicas en ` <Suspense> `. Cada boundary de Suspense se convierte en un agujero dinámico en PPR. Si wrappeás toda tu página en un solo Suspense, recreaste SSR con pase es extra. Sé quirúrgico, el shell debería ser la mayoría de la página.
2. **Tip:** Utilizar ` unstable_noStore() ` en tus funciones de data fetching para marcarlas explícitamente como dinámicas. PPR detecta acceso a datos dinámicos durante el prerender, si un componente llama a ` cookies() `, ` headers() `, o ` searchParams `, se convierte en un agujero dinámico. Pero el marcado explícito previene bugs sutiles.
3. **Tip:** Probar tus páginas PPR con ` next build ` y verificar el output. Next.js muestra qué páginas son completamente estáticas (ícono círculo), cuáles son PPR (círculo medio lleno), y cuáles son completamente dinámicas (ícono lambda). Si una página que esperabas sea PPR aparece como completamente dinámica, uno de tus componentes top-level está accediendo a datos dinámicos fuera de un boundary de Suspense.
4. **Tip:** Para páginas de producto de e-commerce, colocar el precio y estado de stock en un boundary de Suspense pero dejar la descripción del producto, imágenes, y reviews en el shell estático. Los precios cambian cada hora pero las descripciones cambian cada mes, matchear tu estrategia de rendering con tus requerimientos de frescura de datos es todo el punto de PPR.

- nextjs
- ppr
- partial-prerendering
- react
- suspense
- performance
- ssr
- isr
- generación-estática

[Volver al Blog](<https://lucioduran.com/es/blog>)
