Ultima actualización
Habilitar Cache Components
Actualiza next.config.mjs:
const config = {
cacheComponents: true, // ← Habilita Cache Components
};
export default config;Este flag habilita Partial Prerendering (PPR) automáticamente.
Generar rutas estáticas del segmento dinámico
Para que Next.js conozca qué productos prerenderizar para /product/[id], agrega generateStaticParams:
import { db } from "@/lib/db";
export async function generateStaticParams() {
const products = await db.listProducts();
return products.map((product) => ({
id: product.id,
}));
}Sin generateStaticParams, la ruta dinámica sigue funcionando, pero pierdes
parte del valor educativo de ver claramente qué paths se prerenderizan.
Crear queries separadas
Cada campo necesita su propia query:
export const db = {
// Query SOLO para texto
async getProductText(productId: string) {
console.log(`[DB Query] 📝 getProductText - Product ${productId}`);
const { name, description } = await db.query(
"SELECT name, description FROM products WHERE id = ?",
[productId]
);
return { name, description };
},
// Query SOLO para precio
async getProductPrice(productId: string) {
console.log(`[DB Query] 💰 getProductPrice - Product ${productId}`);
const { price } = await db.query(
"SELECT price FROM products WHERE id = ?",
[productId]
);
return { price };
},
// Query SOLO para stock
async getProductStock(productId: string) {
console.log(`[DB Query] 📦 getProductStock - Product ${productId}`);
const { stock } = await db.query(
"SELECT stock FROM products WHERE id = ?",
[productId]
);
return { stock, lastChecked: new Date().toISOString() };
},
};¿Por qué múltiples queries? Aunque parece ineficiente, las queries cacheadas se ejecutan en build time, no en cada request.
Componente de texto (cacheado)
async function ProductText({ productId }: { productId: string }) {
"use cache";
cacheTag(`product-text-${productId}`);
cacheLife("weeks"); // Cache largo: rara vez cambia
const { name, description } = await db.getProductText(productId);
return (
<Card className="border-green-200">
<CardHeader>
<div className="flex items-start justify-between">
<CardTitle>{name}</CardTitle>
<Badge className="bg-green-100 text-green-800">
📦 Cacheado 1 semana
</Badge>
</div>
<CardDescription>{description}</CardDescription>
</CardHeader>
</Card>
);
}Puntos clave:
'use cache'marca el componente como cacheablecacheTagpermite revalidación selectivacacheLife('weeks')define duración del cache- Query específica solo para este campo
Componente de precio (cacheado)
async function ProductPrice({ productId }: { productId: string }) {
"use cache";
cacheTag(`product-price-${productId}`);
cacheLife("hours"); // Cache medio: cambia ocasionalmente
const { price } = await db.getProductPrice(productId);
return (
<Card className="border-blue-200">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardDescription>Precio</CardDescription>
<CardTitle className="text-4xl">${price.toFixed(2)}</CardTitle>
</div>
<Badge className="bg-blue-100 text-blue-800">
⏱️ Cacheado 1 hora
</Badge>
</div>
</CardHeader>
</Card>
);
}Componente de stock (sin cache)
async function ProductStock({ productId }: { productId: string }) {
// Sin 'use cache' - siempre fresh
const { stock, lastChecked } = await db.getProductStock(productId);
return (
<Card className="border-amber-200">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardDescription>Stock disponible</CardDescription>
<CardTitle className={stock < 10 ? "text-red-600" : ""}>
{stock} unidades
</CardTitle>
<p className="text-xs text-muted-foreground">
Verificado: {new Date(lastChecked).toLocaleTimeString()}
</p>
</div>
<Badge className="bg-amber-100 text-amber-800">
🔄 Sin cache (streaming)
</Badge>
</div>
</CardHeader>
</Card>
);
}Sin use cache = el componente se ejecuta en cada request y hace
streaming.
Página principal con Suspense
import { Suspense } from "react";
// Componente principal - SYNC, solo estructura
export default function ProductPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
return (
<div className="container mx-auto px-4 py-8 max-w-4xl">
<Link href="/">← Volver</Link>
{/* Todo el contenido dinámico en Suspense */}
<Suspense fallback={<ProductPageSkeleton />}>
<ProductContent params={params} />
</Suspense>
</div>
);
}
// Contenido - ASYNC, accede a params
async function ProductContent({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return (
<div className="space-y-6">
{/* Texto y precio: cacheados, van al static shell */}
<ProductText productId={id} />
<ProductPrice productId={id} />
{/* Stock: sin cache, requiere Suspense adicional */}
<Suspense fallback={<StockSkeleton />}>
<ProductStock productId={id} />
</Suspense>
</div>
);
}Crítico: params es runtime data y debe accederse dentro de Suspense.
Server Actions para revalidación
"use server";
import { revalidateTag } from "next/cache";
import { z } from "zod";
const productIdSchema = z
.string()
.trim()
.min(1)
.regex(/^[a-zA-Z0-9-]+$/);
function validateProductId(productId: string) {
return productIdSchema.parse(productId);
}
export async function revalidateProductPrice(productId: string) {
const safeProductId = validateProductId(productId);
console.log(`[Server Action] Revalidating price for ${safeProductId}`);
revalidateTag(`product-price-${safeProductId}`, "max");
return { success: true, message: "Precio revalidado" };
}
export async function revalidateProductText(productId: string) {
const safeProductId = validateProductId(productId);
console.log(`[Server Action] Revalidating text for ${safeProductId}`);
revalidateTag(`product-text-${safeProductId}`, "max");
return { success: true, message: "Texto revalidado" };
}Timeline de ejecución
pnpm build
✅ generateStaticParams lista ids estáticos de producto
✅ ProductText ejecuta query → resultado en static shell
✅ ProductPrice ejecuta query → resultado en static shell
❌ ProductStock NO se ejecuta (requiere request)
Output:
- HTML con texto y precio ya renderizados
- Placeholder para stock (Suspense fallback)Usuario visita /product/1
⚡ Browser recibe HTML inmediatamente
- Texto: "Laptop Dell XPS 15" (del static shell)
- Precio: "$1,299.99" (del static shell)
- Stock: <StockSkeleton /> (placeholder)
🔄 Server ejecuta ProductStock
- Query a DB para stock actual
⚡ Stock hace streaming al browser
- Reemplaza <StockSkeleton />
- Usuario ve: "15 unidades"Usuario hace click en "Revalidar Precio"
🔄 revalidateProductPrice() ejecuta
- Marca 'product-price-1' como stale
📝 Próxima visita a /product/1:
- Texto: Sigue del cache (no revalidado)
- Precio: Se regenera en background
- Stock: Se ejecuta fresh (sin cache)Verificación
En desarrollo
pnpm devAbre http://localhost:3000 y verifica en consola del servidor:
[DB Query] 📝 getProductText - Product 1
[DB Query] 💰 getProductPrice - Product 1
[DB Query] 📦 getProductStock - Product 1En producción
pnpm build
pnpm startVerifica en el output del build:
Route (app) Size First Load JS
┌ ◐ / 2.4 kB 170 kB
└ ◐ /product/[id]
◐ Partial Prerender - static HTML + contenido dinámico por streamingSi ves ◐ Partial Prerender para /product/[id], está funcionando
correctamente para este demo de Cache Components.
Debugging
Ver qué se cachea
En el browser, haz View Source (Ctrl+U) y busca:
<!-- Deberías ver el HTML del texto y precio -->
<h1>Laptop Dell XPS 15</h1>
<p>Potente laptop con procesador...</p>
<div>$1,299.99</div>
<!-- El stock NO debería estar, solo el skeleton -->Logs útiles
Agrega logs en cada componente:
async function ProductPrice({ productId }) {
"use cache";
console.log(`[${new Date().toISOString()}] Rendering ProductPrice`);
// ...
}En build, deberías ver el log una sola vez. En request time, NO deberías verlo (usa cache).