tle tags.
When metadata depends on route parameters or external data, use generateMetadata. This async function receives resolved params and a parent metadata object, enabling inheritance and override patterns.
// app/catalog/[itemId]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'
import { fetchItemDetails } from '@/services/catalog'
interface RouteProps {
params: Promise<{ itemId: string }>
}
export async function generateCatalogMeta(
{ params }: RouteProps,
parent: ResolvingMetadata
): Promise<Metadata> {
const { itemId } = await params
const item = await fetchItemDetails(itemId)
if (!item) {
return { title: 'Item Unavailable', robots: 'noindex' }
}
const inheritedImages = (await parent).openGraph?.images ?? []
return {
title: item.name,
description: item.shortDescription,
openGraph: {
title: item.name,
description: item.shortDescription,
type: 'product',
publishedTime: item.createdAt.toISOString(),
images: [
{ url: item.primaryAsset, width: 1200, height: 630, alt: item.name },
...inheritedImages,
],
},
twitter: {
card: 'summary_large_image',
title: item.name,
description: item.shortDescription,
images: [item.primaryAsset],
},
alternates: {
canonical: `https://nexus.example.com/catalog/${itemId}`,
},
}
}
Architecture Rationale: generateMetadata runs during server rendering, allowing direct access to route parameters and async data sources. Resolving parent metadata enables fallback inheritance, ensuring base configurations (like default OG images or site-wide tags) propagate correctly. The alternates.canonical field prevents duplicate content penalties across paginated or parameterized routes.
3. Programmatic Open Graph Images
Static preview images fail to convey context. Next.js 15 provides next/og to generate images dynamically using a React-like API. The framework automatically registers the route and caches the output.
// app/catalog/[itemId]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { fetchItemDetails } from '@/services/catalog'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function CatalogPreview({ params }: { params: { itemId: string } }) {
const item = await fetchItemDetails(params.itemId)
return new ImageResponse(
(
<div
style={{
background: 'linear-gradient(160deg, #0F172A, #1E293B)',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
padding: '72px',
fontFamily: 'system-ui, sans-serif',
}}
>
<span style={{ fontSize: '16px', color: '#60A5FA', letterSpacing: '2px', marginBottom: '16px' }}>
NEXUS CATALOG
</span>
<h1 style={{ fontSize: '52px', fontWeight: 800, color: '#F8FAFC', lineHeight: 1.1 }}>
{item?.name ?? 'Product Listing'}
</h1>
</div>
),
{ ...size }
)
}
Architecture Rationale: ImageResponse compiles JSX into a PNG at request time. Next.js automatically maps this file to /catalog/[itemId]/opengraph-image and injects the URL into the page's metadata. The runtime caches generated images, preventing redundant computation on subsequent crawls. This pattern guarantees that every product, article, or user profile receives a context-aware social preview without manual asset management.
4. Sitemap and Robots Configuration
Sitemaps and robots rules belong in dedicated route files. Next.js treats them as static or dynamic exports based on the implementation.
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { listAllCatalogItems } from '@/services/catalog'
export default async function generateSiteMap(): Promise<MetadataRoute.Sitemap> {
const items = await listAllCatalogItems()
const baseDomain = 'https://nexus.example.com'
return [
{ url: baseDomain, lastModified: new Date(), changeFrequency: 'daily', priority: 1.0 },
{ url: `${baseDomain}/catalog`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
...items.map((entry) => ({
url: `${baseDomain}/catalog/${entry.slug}`,
lastModified: entry.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
})),
]
}
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function configureRobots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/internal/', '/api/v2/'] },
],
sitemap: 'https://nexus.example.com/sitemap.xml',
}
}
Architecture Rationale: Co-locating these files in the app/ directory allows Next.js to serve them at /sitemap.xml and /robots.txt automatically. Dynamic sitemaps fetch live data, ensuring search engines index only active routes. Robots configuration centralizes crawler directives, preventing accidental indexing of internal or rate-limited endpoints.
5. Structured Data Injection
JSON-LD must be injected directly into the page component. The metadata API does not support structured data natively.
// app/catalog/[itemId]/page.tsx
import { fetchItemDetails } from '@/services/catalog'
interface PageProps {
params: Promise<{ itemId: string }>
}
export default async function CatalogPage({ params }: PageProps) {
const { itemId } = await params
const item = await fetchItemDetails(itemId)
if (!item) return null
const structuredData = {
'@context': 'https://schema.org',
'@type': 'Product',
name: item.name,
description: item.shortDescription,
image: item.primaryAsset,
brand: { '@type': 'Brand', name: 'NexusPlatform' },
offers: {
'@type': 'Offer',
priceCurrency: 'USD',
price: item.price.toFixed(2),
availability: 'https://schema.org/InStock',
},
datePublished: item.createdAt.toISOString(),
dateModified: item.updatedAt.toISOString(),
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(structuredData) }}
/>
<main>
<h1>{item.name}</h1>
<p>{item.shortDescription}</p>
</main>
</>
)
}
Architecture Rationale: Injecting JSON-LD via <script> ensures crawlers parse structured data alongside visible content. Using dangerouslySetInnerHTML is necessary here because React escapes HTML by default, but JSON-LD requires raw string injection. The schema aligns with Google's Rich Results guidelines, enabling enhanced search listings.
6. Request Deduplication with cache()
generateMetadata and the page component execute in separate contexts. Without deduplication, fetchItemDetails runs twice per request. React's cache() utility solves this by memoizing async calls within the same render tree.
// services/catalog.ts
import { cache } from 'react'
export const fetchItemDetails = cache(async (itemId: string) => {
const response = await fetch(`https://api.nexus.example.com/items/${itemId}`)
if (!response.ok) return null
return response.json()
})
Architecture Rationale: cache() wraps the async function, ensuring identical arguments return the same promise within a single request lifecycle. This eliminates redundant database queries, reduces latency, and prevents rate-limiting from external APIs. The pattern is framework-agnostic and relies on React's internal request context.
Pitfall Guide
1. The Double-Fetch Trap
Explanation: generateMetadata and the page component run independently. Calling the same data-fetching function in both places triggers duplicate I/O operations.
Fix: Wrap all shared data-fetching functions with cache() from react. Verify deduplication using network inspection or query logging.
Explanation: Forgetting to await parent when merging inherited metadata causes undefined errors or missing fallback values.
Fix: Always resolve the parent object before accessing nested properties: const parentMeta = await parent; const images = parentMeta.openGraph?.images ?? [].
3. OG Image Dimension Mismatch
Explanation: Social platforms enforce strict aspect ratios. Deviating from 1200x630 pixels causes cropping or rejection by crawlers.
Fix: Explicitly define export const size = { width: 1200, height: 630 } in the opengraph-image.tsx file. Test previews using platform-specific debuggers before deployment.
4. JSON-LD Serialization Errors
Explanation: Dates, numbers, and special characters in structured data can break JSON parsing if not properly formatted.
Fix: Use .toISOString() for dates, .toFixed(2) for currency, and validate output with Google's Rich Results Test. Never interpolate raw user input without sanitization.
Explanation: Setting the same canonical URL across paginated routes triggers duplicate content penalties.
Fix: Conditionally set alternates.canonical based on page index. Route 1 should point to the base URL; subsequent pages should include the pagination segment.
Explanation: Social networks cache OG images and metadata aggressively. Updates to dynamic previews may not reflect immediately.
Fix: Implement cache-busting query parameters for preview URLs during development. Use platform debuggers to force cache refreshes before marketing campaigns.
7. Over-Reliance on Static Export for Dynamic Routes
Explanation: Using static export const metadata on parameterized routes forces identical tags across all instances, breaking SEO uniqueness.
Fix: Reserve static metadata for truly static pages. Use generateMetadata for any route accepting parameters or fetching external data.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Static marketing pages | Static export const metadata | Zero runtime overhead, predictable builds | None |
| Parameterized product/article routes | generateMetadata + cache() | Dynamic data binding, deduplicated I/O | Low (server compute) |
| High-traffic social sharing | next/og with edge runtime | Fast image generation, automatic caching | Medium (edge compute) |
| Large catalogs (>10k items) | Split sitemaps + dynamic generation | Prevents XML size limits, improves crawl efficiency | Low (storage) |
| Multi-tenant platforms | Dynamic canonical + tenant-scoped metadata | Prevents cross-tenant content duplication | Low (logic complexity) |
Configuration Template
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: { template: '%s | NexusPlatform', default: 'NexusPlatform' },
description: 'Enterprise developer infrastructure.',
openGraph: {
type: 'website',
locale: 'en_US',
siteName: 'NexusPlatform',
},
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <html lang="en"><body>{children}</body></html>
}
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { listAllRoutes } from '@/services/router'
export default async function generateSiteMap(): Promise<MetadataRoute.Sitemap> {
const routes = await listAllRoutes()
const base = 'https://nexus.example.com'
return [
{ url: base, lastModified: new Date(), changeFrequency: 'daily', priority: 1.0 },
...routes.map((r) => ({
url: `${base}${r.path}`,
lastModified: r.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
})),
]
}
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function configureRobots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/internal/', '/api/'] }],
sitemap: 'https://nexus.example.com/sitemap.xml',
}
}
Quick Start Guide
- Initialize metadata layer: Add title templates and base Open Graph configuration to
app/layout.tsx.
- Create dynamic metadata function: Implement
generateMetadata in parameterized routes, ensuring params and parent are properly typed and resolved.
- Wrap data fetchers with
cache(): Import cache from react and memoize all functions shared between metadata and page components.
- Add programmatic OG images: Create
opengraph-image.tsx in target routes, export size and contentType, and return an ImageResponse with JSX layout.
- Deploy and validate: Run
curl -s https://your-domain.com/route | grep -E 'og:|twitter:|canonical' to verify injection. Test social previews and submit sitemap to search consoles.