BlogCache Components

Ultima actualización

Habilitar Cache Components

Actualiza next.config.mjs:

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:

app/product/[id]/page.tsx
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:

lib/db.ts
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)

app/product/[id]/_components/product-text.tsx
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 cacheable
  • cacheTag permite revalidación selectiva
  • cacheLife('weeks') define duración del cache
  • Query específica solo para este campo

Componente de precio (cacheado)

app/product/[id]/_components/product-price.tsx
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)

app/product/[id]/_components/product-stock.tsx
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

app/product/[id]/page.tsx
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

app/actions.ts
"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)

Verificación

En desarrollo

pnpm dev

Abre http://localhost:3000 y verifica en consola del servidor:

[DB Query] 📝 getProductText - Product 1
[DB Query] 💰 getProductPrice - Product 1
[DB Query] 📦 getProductStock - Product 1

En producción

pnpm build
pnpm start

Verifica 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 streaming

Si 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).

En esta página