Prerrenderizado Parcial (PPR)
Sirve un shell estático al instante y transmite contenido dinámico en límites Suspense -- lo mejor de renderizado estático y dinámico.
Receta
Tarjeta de referencia rápida -- lista para copiar y pegar.
// next.config.ts -- habilitar PPR
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
ppr: true, // o "incremental" para optar por ruta
},
};
export default config;// app/page.tsx -- shell estático con agujeros dinámicos
import { Suspense } from "react";
import { cookies } from "next/headers";
export default function Page() {
return (
<main>
<h1>Bienvenido a la Tienda</h1> {/* Shell estático */}
<StaticHero /> {/* Shell estático */}
<Suspense fallback={<CartSkeleton />}>
<DynamicCart /> {/* Agujero dinámico -- transmitido */}
</Suspense>
</main>
);
}
async function DynamicCart() {
const cookieStore = await cookies();
const cartId = cookieStore.get("cart-id")?.value;
const cart = await fetchCart(cartId);
return <CartWidget items={cart.items} />;
}Cuándo usarlo: Tienes una página donde la mayoría del contenido es estático (información de producto, texto de marketing) pero una pequeña parte depende de la solicitud (sesión del usuario, carrito, personalización).
Ejemplo Funcional
// next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
ppr: "incremental", // optar por ruta
},
};
export default config;// app/products/[slug]/page.tsx
import { Suspense } from "react";
import { cookies } from "next/headers";
import { db } from "@/lib/db";
import { AddToCartButton } from "./add-to-cart";
// Optar esta ruta en PPR
export const experimental_ppr = true;
// Pregenerar páginas de producto en tiempo de compilación
export async function generateStaticParams() {
const products = await db.product.findMany({ select: { slug: true } });
return products.map((p) => ({ slug: p.slug }));
}
type Props = { params: Promise<{ slug: string }> };
export default async function ProductPage({ params }: Props) {
const { slug } = await params;
const product = await db.product.findUnique({ where: { slug } });
if (!product) return <p>Producto no encontrado</p>;
return (
<main className="max-w-4xl mx-auto p-6">
{/* ---- Shell Estático (prerrenderizado en tiempo de compilación) ---- */}
<div className="grid grid-cols-2 gap-8">
<img
src={product.imageUrl}
alt={product.name}
className="w-full rounded"
/>
<div>
<h1 className="text-3xl font-bold">{product.name}</h1>
<p className="text-2xl text-gray-700 mt-2">
${product.price.toFixed(2)}
</p>
<p className="mt-4 text-gray-600">{product.description}</p>
<AddToCartButton productId={product.id} />
</div>
</div>
{/* ---- Agujeros Dinámicos (transmitidos en tiempo de solicitud) ---- */}
<section className="mt-12">
<h2 className="text-xl font-bold mb-4">Personalizado para Ti</h2>
<Suspense fallback={<RecommendationsSkeleton />}>
<PersonalizedRecommendations />
</Suspense>
</section>
<section className="mt-8">
<h2 className="text-xl font-bold mb-4">Tus Vistas Recientes</h2>
<Suspense fallback={<RecentViewsSkeleton />}>
<RecentlyViewed />
</Suspense>
</section>
</main>
);
}
// ---- Componentes Dinámicos (usan datos en tiempo de solicitud) ----
async function PersonalizedRecommendations() {
const cookieStore = await cookies();
const userId = cookieStore.get("user-id")?.value;
if (!userId) return <p>Inicia sesión para recomendaciones personalizadas</p>;
const recs = await fetch(
`https://api.example.com/recommendations?user=${userId}`,
{ cache: "no-store" }
);
const products = await recs.json();
return (
<div className="grid grid-cols-4 gap-4">
{products.map((p: { id: string; name: string; price: number }) => (
<div key={p.id} className="border rounded p-3">
<p className="font-medium">{p.name}</p>
<p className="text-gray-500">${p.price.toFixed(2)}</p>
</div>
))}
</div>
);
}
async function RecentlyViewed() {
const cookieStore = await cookies();
const history = cookieStore.get("view-history")?.value;
if (!history) return <p>Sin vistas recientes</p>;
const ids = JSON.parse(history) as string[];
const products = await db.product.findMany({
where: { id: { in: ids } },
});
return (
<div className="flex gap-4 overflow-x-auto">
{products.map((p) => (
<div key={p.id} className="min-w-[150px] border rounded p-3">
<p className="font-medium">{p.name}</p>
</div>
))}
</div>
);
}
// ---- Skeletons ----
function RecommendationsSkeleton() {
return (
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 bg-gray-100 rounded animate-pulse" />
))}
</div>
);
}
function RecentViewsSkeleton() {
return (
<div className="flex gap-4">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="w-[150px] h-20 bg-gray-100 rounded animate-pulse" />
))}
</div>
);
}Lo que esto demuestra:
- Habilitar PPR incrementalmente por ruta con
experimental_ppr = true - La información del producto (nombre, precio, descripción, imagen) es el shell estático -- prerrenderizado en tiempo de compilación y servido desde el CDN
- Las recomendaciones personalizadas y productos vistos recientemente son agujeros dinámicos -- usan
cookies()y se transmiten en tiempo de solicitud - Los fallbacks de skeleton aparecen al instante como parte del shell estático
generateStaticParamsprerrenderiza páginas de producto en tiempo de compilación
Profundización
Cómo Funciona
- Con PPR, Next.js prerrenderiza las partes estáticas de una página en tiempo de compilación, incluyendo los fallbacks de Suspense. El resultado es un shell HTML completo almacenado en el CDN.
- Cuando llega una solicitud, el shell estático se envía inmediatamente (latencia de edge). El servidor luego reanuda el renderizado de las porciones dinámicas (componentes que llaman
cookies(),headers(),searchParams, ocache: "no-store"). - El contenido dinámico es transmitido en los límites de Suspense, reemplazando los skeletons de fallback conforme cada límite se resuelve.
- La idea clave: Los límites de Suspense definen la división estática/dinámica. Todo lo que está fuera de un límite de Suspense con un hijo dinámico es parte del shell estático. Todo lo que está dentro es un agujero dinámico.
- PPR combina el rendimiento de la generación estática (TTFB instantáneo desde CDN) con la flexibilidad del renderizado dinámico (contenido personalizado en tiempo de solicitud).
Variaciones
Adopción incremental (optar por ruta):
// next.config.ts
export default { experimental: { ppr: "incremental" } };
// Solo esta página usa PPR
// app/products/[slug]/page.tsx
export const experimental_ppr = true;Todas las rutas PPR (global):
// next.config.ts
export default { experimental: { ppr: true } };
// Todas las rutas automáticamente usan PPRMúltiples agujeros dinámicos con fuentes de datos independientes:
export default function Page() {
return (
<main>
<StaticNav />
<StaticHero />
{/* Cada límite Suspense es un agujero dinámico independiente */}
<Suspense fallback={<UserBarSkeleton />}>
<UserBar /> {/* cookies() */}
</Suspense>
<Suspense fallback={<PricingSkeleton />}>
<LivePricing /> {/* cache: "no-store" */}
</Suspense>
<Suspense fallback={<WeatherSkeleton />}>
<LocalWeather /> {/* headers() para geo */}
</Suspense>
<StaticFooter />
</main>
);
}Notas de TypeScript
// Exportación de optar por ruta en PPR
export const experimental_ppr: boolean = true;
// Tipo next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
experimental: {
ppr: true | "incremental",
},
};Gotchas
-
PPR es experimental -- A partir de Next.js 15/16, PPR requiere el flag
experimental.ppr. La API puede cambiar. Solución: Fija tu versión de Next.js y prueba exhaustivamente antes del uso en producción. -
La colocación del límite Suspense determina la división -- Si olvidas envolver un componente dinámico en Suspense, la página completa se vuelve dinámica. Solución: Siempre envuelve componentes que usan funciones dinámicas en un límite
<Suspense>. -
La calidad del fallback importa -- El fallback de Suspense es parte del shell estático y es visible instantáneamente para todos los usuarios. Un fallback pobre crea una mala primera impresión. Solución: Usa skeletons bien diseñados que coincidan con la forma y tamaño del contenido real.
-
Los agujeros dinámicos añaden costo de servidor -- Cada agujero dinámico requiere computación de servidor en tiempo de solicitud. Demasiados agujeros dinámicos anulan los beneficios del CDN. Solución: Consolida datos dinámicos relacionados en menos límites de Suspense.
-
No PPR en Edge Runtime para todos los proveedores -- PPR requiere que la plataforma de hosting soporte streaming y la API postpone. Solución: Verifica que tu plataforma de despliegue soporte PPR (Vercel lo soporta nativamente).
-
Los componentes de cliente en el shell estático siguen enviando JavaScript -- PPR hace el HTML estático, pero los componentes de cliente siguen hidratándose en el cliente. Solución: Mantén el shell estático compuesto principalmente de Server Components para JavaScript mínimo.
Alternativas
| Enfoque | Úsalo Cuando | No lo Uses Cuando |
|---|---|---|
| PPR | La mayoría de la página es estática pero algunas partes necesitan datos en tiempo de solicitud | Toda la página es completamente estática o completamente dinámica |
| Generación Completamente Estática | Toda la página puede compilarse en tiempo de despliegue | Cualquier parte necesita datos específicos del usuario |
| Renderizado Completamente Dinámico | Toda la página depende de datos en tiempo de solicitud | La mayoría del contenido es igual para todos los usuarios |
| Fetch del lado del cliente | El contenido dinámico debe cargar después de la hidratación, sin bloquear SSR | Quieres los datos dinámicos en el HTML inicial |
| ISR | El contenido cambia periódicamente pero no por solicitud | El contenido es personalizado por usuario |
| Streaming SSR (sin PPR) | Quieres renderizado progresivo sin un shell estático | Quieres que el shell se sirva desde el borde CDN |
FAQs
¿Qué es Prerrenderizado Parcial (PPR)?
PPR combina generación estática y renderizado dinámico en una sola ruta. Las partes estáticas se prerrenderan en tiempo de compilación y se sirven desde el CDN. Las partes dinámicas se transmiten en límites de Suspense en tiempo de solicitud.
¿Cómo habilito PPR en mi proyecto Next.js?
Añade experimental: { ppr: true } (global) o experimental: { ppr: "incremental" } (optar por ruta) a next.config.ts. Para modo incremental, también exporta experimental_ppr = true en el archivo de la página.
¿Qué determina el límite entre el shell estático y un agujero dinámico?
Los límites de Suspense definen la división. Todo lo que está fuera de un límite de Suspense es parte del shell estático. Los componentes dentro de un límite de Suspense que llaman funciones dinámicas (cookies(), headers(), cache: "no-store") se vuelven agujeros dinámicos.
¿Qué sucede con el fallback de Suspense en PPR?
El fallback es parte del shell estático -- se prerrenderiza y se sirve al instante desde el CDN. Los usuarios ven el skeleton inmediatamente mientras el contenido dinámico se transmite.
Gotcha: Olvidé envolver un componente dinámico en Suspense. ¿Qué sucede?
Toda la página se vuelve dinámica. Sin un límite de Suspense, PPR no puede identificar la división estática/dinámica, por lo que la ruta completa se retrotrae al renderizado completamente dinámico.
Gotcha: ¿Los componentes de cliente en el shell estático siguen enviando JavaScript?
Sí. PPR hace el HTML estático, pero los componentes de cliente siguen hidratándose en el cliente y enviando su JavaScript. Mantén el shell estático compuesto principalmente de Server Components para JS mínimo.
¿Puedo tener múltiples agujeros dinámicos independientes en una página?
Sí. Cada límite de Suspense es un agujero dinámico independiente con su propia fuente de datos:
<Suspense fallback={<UserBarSkeleton />}>
<UserBar /> {/* cookies() */}
</Suspense>
<Suspense fallback={<PricingSkeleton />}>
<LivePricing /> {/* cache: "no-store" */}
</Suspense>¿Cómo optar una sola ruta en PPR con modo incremental?
// next.config.ts
export default { experimental: { ppr: "incremental" } };
// app/products/[slug]/page.tsx
export const experimental_ppr = true;¿Cuál es el tipo de TypeScript para la exportación experimental_ppr?
export const experimental_ppr: boolean = true;En next.config.ts, la opción PPR se tipifica como true | "incremental" dentro del objeto experimental de NextConfig.
¿Funciona PPR con generateStaticParams?
Sí. generateStaticParams prerrenderiza páginas de producto en tiempo de compilación como el shell estático. Los agujeros dinámicos dentro de esas páginas se transmiten en tiempo de solicitud.
¿Cuáles son los requisitos de hosting para PPR?
PPR requiere una plataforma de hosting que soporte streaming y la API postpone. Vercel lo soporta nativamente. Verifica tu plataforma de despliegue antes de habilitar PPR en producción.
¿Por qué debo limitar el número de agujeros dinámicos?
Cada agujero dinámico requiere computación de servidor en tiempo de solicitud. Demasiados agujeros dinámicos anulan los beneficios de rendimiento del CDN del shell estático. Consolida datos dinámicos relacionados en menos límites de Suspense cuando sea posible.
Relacionado
- Estático vs Dinámico -- Entendiendo los modos de renderizado
- Streaming -- Cómo Suspense habilita el renderizado progresivo
- Server Components -- Los bloques de construcción del shell estático
- Cookies & Headers -- Funciones dinámicas que crean agujeros dinámicos