Last update
Enable Cache Components
Update 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:
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:
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)
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 cacheablecacheTagallows selective revalidationcacheLife('weeks')defines cache duration- Specific query just for this field
Price component (cached)
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)
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
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
"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)User visits /product/1
⚡ Browser receives HTML immediately
- Text: "Laptop Dell XPS 15" (from static shell)
- Price: "$1,299.99" (from static shell)
- Stock: <StockSkeleton /> (placeholder)
🔄 Server executes ProductStock
- Query DB for current stock
⚡ Stock streams to the browser
- Replaces <StockSkeleton />
- User sees: "15 units"User clicks "Revalidate Price"
🔄 revalidateProductPrice() executes
- Marks 'product-price-1' as stale
📝 Next visit to /product/1:
- Text: Still from cache (not revalidated)
- Price: Regenerated in background
- Stock: Runs fresh (no cache)Verification
In development
pnpm devOpen http://localhost:3000 and check the server console:
[DB Query] 📝 getProductText - Product 1
[DB Query] 💰 getProductPrice - Product 1
[DB Query] 📦 getProductStock - Product 1In production
pnpm build
pnpm startCheck the build output:
Route (app) Size First Load JS
| ◐ / 2.4 kB 170 kB
| ◐ /product/[id]
◐ Partial Prerender - static HTML + dynamic streaming contentIf 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).