Next.js Partial Prerendering (PPR) a fondo: cómo funciona, cuándo usarlo y por qué lo cambia todo
Todo dev de Next.js enfrentó la misma elección imposible: ¿estático o dinámico? Pre-renderizar la página en build time para velocidad (SSG), o renderizar en cada request para datos frescos (SSR). Elegí uno. No podés tener ambos.
Hasta ahora. Partial Prerendering (PPR) es el cambio arquitectónico más importante en Next.js desde el App Router. Te permite servir un shell estático instantáneamente (headers, navegación, layout, contenido above-the-fold) mientras streamea las partes dinámicas (datos por usuario, precios en tiempo real, recomendaciones personalizadas) en la misma respuesta HTTP. Sin fetches del lado del cliente. Sin layout shift. Una request, una respuesta.
Esto no es incremental. Es el fin del árbol de decisión SSG vs. SSR. Vamos a ver cómo funciona por dentro, cómo implementarlo, y dónde se complica.
El problema que resuelve PPR
Antes de PPR, tenías cuatro estrategias de renderizado en Next.js, y cada una venía con un tradeoff doloroso:
| Estrategia | Velocidad | Frescura | Personalización |
|---|---|---|---|
| SSG | ⚡ Instantáneo | ❌ Stale hasta rebuild | ❌ Igual para todos |
| ISR | ⚡ Rápido | ⚠️ Stale dentro del window de revalidation | ❌ Igual para todos |
| SSR | 🐌 TTFB lento | ✅ Siempre fresco | ✅ Por usuario |
| CSR | 🐌 FCP lento | ✅ Siempre fresco | ✅ Por usuario |
El problema real: tomá una página de producto de e-commerce:
- El título, descripción, imágenes del producto rara vez cambian → deberían ser estáticos
- El precio, stock, reviews cambian constantemente → necesitan ser dinámicos
- Las recomendaciones personalizadas son por usuario → requieren personalización
Con SSG, tus precios son viejos. Con SSR, el TTFB es terrible porque esperás a que la base de datos devuelva precio, stock, Y recomendaciones antes de enviar un solo byte. Con CSR, el usuario mira skeletons mientras tres API calls se resuelven.
PPR elimina toda esta matriz. Una página, una request: las partes estáticas llegan al instante, las dinámicas se streamean a medida que se resuelven.
Cómo funciona PPR por dentro
PPR no es magia, pero la arquitectura es bastante ingeniosa. Mirá lo que pasa cuando una request llega a una página con PPR habilitado.
Paso 1: Build time — Generar el shell estático
En next build, Next.js renderiza tu página como haría para SSG. Pero cuando encuentra un <Suspense> boundary envolviendo un componente dinámico, se detiene. No intenta resolver ese componente. En su lugar:
- Renderiza todo lo que está fuera de
<Suspense>en un shell HTML estático - Inyecta un fallback placeholder donde existe cada
<Suspense> - Guarda este shell en el CDN/edge, listo para servir instantáneamente
// 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 en build time return ( <main> {/* Estático: renderizado en build time, servido desde CDN */} <ProductHeader product={product} /> {/* Dinámico: streameado en request time */} <Suspense fallback={<PricingSkeleton />}> <PricingSection productId={params.id} /> </Suspense> {/* Dinámico: streameado en request time */} <Suspense fallback={<RecommendationsSkeleton />}> <Recommendations productId={params.id} /> </Suspense> </main> ); }
Paso 2: Request time — Servir shell + streamear partes dinámicas
Cuando un usuario pide /product/123:
- El CDN sirve el shell estático pre-construido instantáneamente
- Simultáneamente, el servidor empieza a ejecutar los componentes dinámicos dentro de
<Suspense> - A medida que cada componente dinámico se resuelve, su HTML se streamea en la respuesta
Todo esto pasa en una sola request HTTP. El browser empieza a pintar el shell estático mientras las partes dinámicas siguen resolviéndose en el servidor.
Paso 3: El browser recibe una respuesta progresiva
Tiempo (ms) Lo que ve el usuario
──────────────────────────────────────────
0 Request enviada
50 Shell estático llega → ¡La página es visible!
120 Datos de precio llegan → Skeleton reemplazado por precio real
200 Recomendaciones llegan → Skeleton reemplazado por cards
Compará esto con SSR tradicional:
Tiempo (ms) Lo que ve el usuario
──────────────────────────────────────────
0 Request enviada
350 NADA — servidor esperando TODOS los datos
350 Página completa llega de golpe
Activando PPR: paso a paso
PPR se introdujo como experimental en Next.js 14, se refinó en Next.js 15, y se lanzó como estable en Next.js 16 (octubre 2025) a través de Cache Components. En Next.js 16, el flag experimental.ppr fue removido y reemplazado por la configuración cacheComponents.
1. Actualizar next.config.ts
// next.config.ts — Next.js 16+ const nextConfig = { cacheComponents: true, }; export default nextConfig;
Nota: Si seguís en Next.js 14 o 15, usá el flag experimental anterior:
const nextConfig = { experimental: { ppr: true } };
En Next.js 16, todo el código es dinámico por defecto. Optás partes de tu página al cacheo estático usando la directiva "use cache" o estructurando tus componentes con Suspense boundaries. Es lo inverso a versiones anteriores donde las páginas eran estáticas por defecto.
2. Estructurar tu página con Suspense boundaries
La clave: Los Suspense boundaries son la línea divisoria entre estático y dinámico. Todo lo que está fuera de un <Suspense> se pre-renderiza en build time. Todo lo que está adentro se difiere a 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. Hacer que los componentes dinámicos sean realmente dinámicos
Un componente se vuelve dinámico cuando accede a datos 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> ); }
Lo que hace dinámico a un componente:
cookies()— leer cookies de la requestheaders()— leer headers de la requestsearchParams— acceder a query parametersconnection()— opt-in a renderizado dinámicofetch()sin cache —fetch(url, { cache: "no-store" })
Lo que se mantiene estático:
- Componentes sin dependencias de datos dinámicos
- Componentes usando solo
fetch()cacheado - Componentes con
generateStaticParams()
Por qué PPR es fundamentalmente distinto
PPR no es simplemente "SSR con streaming" (eso ya existía con loading.tsx). La diferencia crítica es el shell estático:
Sin PPR (Streaming SSR estándar)
Request → Servidor computa TODO → Streamea progresivamente
└── Sigue esperando que el layout más externo resuelva
└── TTFB depende del componente padre más lento
Con PPR
Request → CDN sirve shell estático pre-construido INSTANTÁNEAMENTE
└── Servidor solo computa componentes wrapped en Suspense
└── TTFB = latencia del CDN (~20-50ms desde edge)
El shell estático se sirve desde el CDN edge, no desde tu servidor de origen. Tu TTFB lo determina la proximidad del usuario al edge node más cercano, no la velocidad de tu query a la base de datos.
Performance real: antes y después de PPR
Métricas concretas de una página de producto de e-commerce en producción.
| Métrica | Solo SSR | PPR | Mejora |
|---|---|---|---|
| TTFB | 380ms | 32ms | 11.9x más rápido |
| FCP | 420ms | 65ms | 6.5x más rápido |
| LCP | 680ms | 65ms | 10.5x más rápido |
| CLS | 0.02 | 0.00 | Eliminado |
Patrones comunes y anti-patrones
✅ Patrón: Suspense boundaries granulares
Envolvé cada dependencia de datos independiente en su propio Suspense boundary:
// ✅ Bueno: streaming independiente <Suspense fallback={<PriceSkeleton />}> <Price productId={id} /> </Suspense> <Suspense fallback={<ReviewsSkeleton />}> <Reviews productId={id} /> </Suspense>
❌ Anti-patrón: Un Suspense boundary gigante
No envuelvas todo en un solo Suspense boundary. Hacés toda la página dinámica y PPR pierde todo el sentido:
❌ Anti-patrón: APIs dinámicas en el Layout
Si tu root layout lee cookies o headers, toda la página se vuelve dinámica:
// ❌ Esto hace TODA la 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>; }
Mové el acceso a datos dinámicos a componentes wrapped en 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. otras estrategias: framework de decisión
Usá PPR cuando:
- Páginas con contenido estático y dinámico mezclado — Páginas de producto, dashboards
- TTFB importa — E-commerce, sitios de contenido, páginas SEO-critical
- Querés personalización sin sacrificar velocidad — Recomendaciones, A/B tests
Mantené SSG puro cuando:
- Toda la página es estática — Blogs, documentación, marketing
- Sin personalización necesaria
Mantené SSR cuando:
- Cada byte depende de la request — Flujos de autenticación, admin panels
- Consistencia de datos es crítica
Migración de SSR a PPR
Paso 1: Identificar boundaries estáticos/dinámicos
grep -rn "cookies()\|headers()\|searchParams\|connection()\|no-store" \ --include="*.tsx" --include="*.ts" ./app
Paso 2: Envolver componentes dinámicos en Suspense
// Antes: todo dinámico export default async function Page() { const user = await getUser(); const posts = await getPosts(); return ( <div> <ProfileCard user={user} /> <PostList posts={posts} /> </div> ); } // Después: shell estático + streaming dinámico export default function Page() { return ( <div> <Suspense fallback={<ProfileSkeleton />}> <ProfileCard /> </Suspense> <PostList /> {/* Estático */} </div> ); }
Paso 3: Activar PPR y buildear
// next.config.ts — Next.js 16+ const nextConfig = { cacheComponents: true, }; // Next.js 14/15 (legacy): // const nextConfig = { experimental: { ppr: true } };
next build
En el output del build, las páginas PPR se marcan con ◐:
◐ /product/[id] Partial Prerendering
○ /about Static
● /blog/[slug] SSG
Debugging: problemas comunes
Problema 1: Toda la página se vuelve dinámica
Síntoma: Build output muestra tu página como fully dynamic (λ) en vez de partial (◐).
Causa: Una API dinámica se llama fuera de un Suspense boundary.
Fix: Buscá cookies(), headers(), connection() y movelos dentro de componentes wrapped en Suspense.
Problema 2: Layout shift a pesar de los skeletons
Fix: Asegurate de que los skeletons tengan min-height o aspect-ratio explícitos que matcheen el contenido final:
<div style={{ contain: "layout", minHeight: "200px" }}> <Suspense fallback={<Skeleton />}> <DynamicComponent /> </Suspense> </div>
El panorama más amplio: por qué PPR importa más allá del rendimiento
PPR no es solo una optimización de performance. Cambia fundamentalmente cómo pensamos la arquitectura web:
-
El fin de las decisiones de renderizado full-page: Ya no elegís SSG o SSR. Elegís SSG y SSR, por componente, dentro de la misma página.
-
Edge-First por defecto: El shell estático vive en el CDN edge. Tu servidor de origen solo maneja las partes dinámicas.
-
Progressive Enhancement incorporado: Usuarios con conexiones lentas ven el shell estático inmediatamente. El contenido dinámico llega cuando el ancho de banda lo permite.
-
Cacheo simplificado: Las partes estáticas son trivialmente cacheables para siempre (basado en content-hash). Las partes dinámicas nunca se cachean.
-
Alineado con la dirección de React: PPR es la convergencia natural de React Server Components, Suspense y streaming.
Conclusión
Partial Prerendering elimina el tradeoff más viejo y doloroso del desarrollo web: estático vs. dinámico. Aprovechando React Suspense como boundary entre contenido pre-renderizado y computación en request-time, PPR entrega TTFB a velocidad de CDN mientras sigue sirviendo datos personalizados en tiempo real.
El modelo mental es simple: todo fuera de <Suspense> es estático. Todo adentro es dinámico. El framework se encarga del resto.
Con Cache Components de Next.js 16, PPR graduó de experimental a estable. Para la mayoría de las aplicaciones Next.js, PPR debería ser la estrategia de renderizado por defecto de ahora en más.
Empezá con cacheComponents: true en tu next.config.ts. Buildeá. Si ves el indicador ◐, acabás de hacer tus páginas más rápidas que nunca.
Explora herramientas relacionadas
Prueba estas herramientas gratuitas de Pockit