BlogCache Components

Last update

Enable Cache Components

Update next.config.mjs:

next.config.mjs
const config = {
  cacheComponents: true, // ← Enable Cache Components
};

export default config;

This flag enables Partial Prerendering (PPR) automatically.

Generate static routes for the dynamic segment

For Next.js to know which products to prerender for /product/[id], add 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,
  }));
}

Without generateStaticParams, the dynamic route still works, but you lose part of the educational value of clearly seeing which paths are prerendered.

Create separate queries

Each field needs its own query:

lib/db.ts
export const db = {
  // Query ONLY for text
  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 ONLY for price
  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 ONLY for 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() };
  },
};

Why multiple queries? Although it seems inefficient, cached queries are executed at build time, not on every request.

Text component (cached)

app/product/[id]/_components/product-text.tsx
async function ProductText({ productId }: { productId: string }) {
  "use cache";
  cacheTag(`product-text-${productId}`);
  cacheLife("weeks"); // Long cache: rarely changes

  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">
            📦 Cached 1 week
          </Badge>
        </div>
        <CardDescription>{description}</CardDescription>
      </CardHeader>
    </Card>
  );
}

Key points:

  • 'use cache' marks the component as cacheable
  • cacheTag allows selective revalidation
  • cacheLife('weeks') defines cache duration
  • Specific query just for this field

Price component (cached)

app/product/[id]/_components/product-price.tsx
async function ProductPrice({ productId }: { productId: string }) {
  "use cache";
  cacheTag(`product-price-${productId}`);
  cacheLife("hours"); // Medium cache: changes occasionally

  const { price } = await db.getProductPrice(productId);

  return (
    <Card className="border-blue-200">
      <CardHeader>
        <div className="flex items-center justify-between">
          <div>
            <CardDescription>Price</CardDescription>
            <CardTitle className="text-4xl">${price.toFixed(2)}</CardTitle>
          </div>
          <Badge className="bg-blue-100 text-blue-800">⏱️ Cached 1 hour</Badge>
        </div>
      </CardHeader>
    </Card>
  );
}

Stock component (no cache)

app/product/[id]/_components/product-stock.tsx
async function ProductStock({ productId }: { productId: string }) {
  // No 'use cache' - always 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 available</CardDescription>
            <CardTitle className={stock < 10 ? "text-red-600" : ""}>
              {stock} units
            </CardTitle>
            <p className="text-xs text-muted-foreground">
              Verified: {new Date(lastChecked).toLocaleTimeString()}
            </p>
          </div>
          <Badge className="bg-amber-100 text-amber-800">
            🔄 No cache (streaming)
          </Badge>
        </div>
      </CardHeader>
    </Card>
  );
}

No use cache = the component executes on every request and streams.

Main page with Suspense

app/product/[id]/page.tsx
import { Suspense } from "react";

// Main component - SYNC, only structure
export default function ProductPage({
  params,
}: {
  params: Promise<{ id: string }>;
}) {
  return (
    <div className="container mx-auto px-4 py-8 max-w-4xl">
      <Link href="/">← Go back</Link>

      {/* All dynamic content in Suspense */}
      <Suspense fallback={<ProductPageSkeleton />}>
        <ProductContent params={params} />
      </Suspense>
    </div>
  );
}

// Content - ASYNC, accesses params
async function ProductContent({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params;

  return (
    <div className="space-y-6">
      {/* Text and price: cached, go to static shell */}
      <ProductText productId={id} />
      <ProductPrice productId={id} />

      {/* Stock: no cache, requires additional Suspense */}
      <Suspense fallback={<StockSkeleton />}>
        <ProductStock productId={id} />
      </Suspense>
    </div>
  );
}

Critical: params is runtime data and must be accessed inside Suspense.

Server Actions for revalidation

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: "Price revalidated" };
}

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: "Text revalidated" };
}

Execution Timeline

pnpm build

✅ generateStaticParams lists static product IDs

✅ ProductText executes query → result in static shell
✅ ProductPrice executes query → result in static shell
❌ ProductStock DOES NOT execute (requires request)

Output:
- HTML with text and price already rendered
- Placeholder for stock (Suspense fallback)

Verification

In development

pnpm dev

Open http://localhost:3000 and check the server console:

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

In production

pnpm build
pnpm start

Check the build output:

Route (app)                    Size     First Load JS
| ◐ /                          2.4 kB         170 kB
| ◐ /product/[id]

◐  Partial Prerender  - static HTML + dynamic streaming content

If you see ◐ Partial Prerender for /product/[id], it's working correctly for this Cache Components demo.

Debugging

Seeing what is cached

In the browser, View Source (Ctrl+U) and look for:

<!-- You should see the HTML for text and price -->
<h1>Laptop Dell XPS 15</h1>
<p>Powerful laptop with processor...</p>
<div>$1,299.99</div>

<!-- Stock should NOT be there, only the skeleton -->

Useful logs

Add logs in each component:

async function ProductPrice({ productId }) {
  "use cache";
  console.log(`[${new Date().toISOString()}] Rendering ProductPrice`);
  // ...
}

In build, you should see the log only once. In request time, you SHOULD NOT see it (it uses cache).

On this page