Here's a production-ready solution for implementing dynamic metadata on Next.js 14+ product detail pages using the App Router.
1. File Tree
`` app/ products/ [productId]/ page.tsx ``
2. Server Component (`page.tsx`)
```tsx import { notFound } from 'next/navigation'; import { getProductById } from '@/lib/data';
export default async function ProductPage({ params }: { params: { productId: string } }) { const product = await getProductById(params.productId); if (!product) notFound();
return ( <main> <h1>{product.name}</h1> <p>{product.description}</p> <p>Price: ${product.price}</p> <img src={product.imageUrl} alt={product.name} width={500} height={500} /> </main> ); } ```
3. Metadata Function (`page.tsx`)
```tsx import type { Metadata } from 'next'; import { getProductById } from '@/lib/data';
type Props = { params: { productId: string } };
export async function generateMetadata({ params }: Props): Promise<Metadata> { const product = await getProductById(params.productId); if (!product) return {};
const productUrl = https://yourdomain.com/products/${product.id}; const imageUrl = product.imageUrl || 'https://yourdomain.com/default-image.jpg'; const descriptionSnippet = product.description.substring(0, 150);
return { title: ${product.name} - Buy Now!, description: descriptionSnippet, alternates: { canonical: productUrl }, openGraph: { title: ${product.name} - Official Store, description: descriptionSnippet, url: productUrl, images: [{ url: imageUrl, width: 800, height: 600, alt: product.name }], type: 'product', }, twitter: { card: 'summary_large_image', site: '@yourcompany', creator: '@yourcompany', title: ${product.name} - Check it out!, description: descriptionSnippet, images: [imageUrl], }, jsonLd: { '@context': 'https://schema.org', '@type': 'Product', name: product.name, description: product.description, image: imageUrl, offers: { '@type': 'Offer', priceCurrency: 'USD', price: product.price, itemCondition: 'https://schema.org/NewCondition', availability: 'https://schema.org/InStock', url: productUrl, }, }, }; } ```
4. Data Layer
The getProductById function (e.g., in lib/data.ts) handles data fetching. It should query your database or API using fetch.
``typescript // lib/data.ts export async function getProductById(productId: string) { const res = await fetch(https://api.yourdomain.com/products/${productId}, { next: { tags: [product-${productId}] }, }); return res.ok ? res.json() : null; } ` This function is called once per request for both generateMetadata` and the page component due to Next.js's Request Memoization.
5. Caching Notes
Next.js memoizes fetch requests within a single render pass. Data is fetched once even if getProductById is called by both generateMetadata and the page component. The Data Cache stores fetch results. For specific product updates, use revalidateTag (e.g., revalidateTag("product-${productId}")) or revalidatePath to clear the cache.
6. Edge/Runtime Choice
generateMetadata runs on the server. Data fetching within it directly impacts server response time. Ensure your data layer is efficient. Consider database read replicas or CDNs for image assets if deploying to edge runtimes to minimize latency.