Static metadata works for fixed pages, but most apps need dynamic metadata that reflects current content. The generateMetadata function generates metadata based on route parameters, database content, or API data. It's async, type-safe, and runs automatically for every page. Build SEO-optimized pages that show current titles, descriptions, and images from your database!
Why Dynamic Metadata?
❌ Static Metadata Problem
// Static metadata doesn't know page content
export const metadata = {
title: 'Blog Post',
description: 'A blog post',
};
// Problems:
// ❌ Same title for all blog posts
// ❌ Generic description
// ❌ No actual content info
// ❌ Poor SEO
// ❌ Bad social previews✅ Dynamic Metadata Solution
// Dynamic metadata from database
export async function generateMetadata({ params }) {
const post = await getPost(params.slug);
return {
title: post.title,
description: post.excerpt,
};
}
// Benefits:
// ✅ Unique title per post
// ✅ Actual content description
// ✅ SEO optimized
// ✅ Rich social previews
// ✅ Always currentWhen to Use Dynamic Metadata
- Blog posts from database
- Product pages with current info
- User profiles
- Dynamic content routes
- Search results pages
- Any page where content determines metadata
generateMetadata Basics
Basic Example
import { Metadata } from 'next';
interface Props {
params: { slug: string };
searchParams: { [key: string]: string | string[] | undefined };
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
// Access route parameters
const slug = params.slug;
// Fetch data (can be async!)
const post = await fetch(`https://api.example.com/posts/${slug}`)
.then(res => res.json());
// Return metadata object
return {
title: post.title,
description: post.excerpt,
};
}
export default async function BlogPostPage({ params }: Props) {
const post = await fetch(`https://api.example.com/posts/${params.slug}`)
.then(res => res.json());
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
);
}
// ✅ Async metadata generation
// ✅ Access route parameters
// ✅ Fetch from any source
// ✅ Type-safe with TypeScript
// ✅ Automatic deduplication (fetch request cached)generateMetadata Signature
export async function generateMetadata(
{ params, searchParams }: Props,
parent?: ResolvingMetadata
): Promise<Metadata> {
// params: Route parameters (e.g., { slug: 'my-post' })
// searchParams: URL query params (e.g., { category: 'tech' })
// parent: Parent metadata (optional, for extending)
return {
title: 'Dynamic Title',
// ... other metadata
};
}
// Returns: Metadata object
// Params: { params, searchParams }
// Optional: parent metadata for extendingMetadata from Database
Blog Post Example
// Database helper
export async function getPost(slug: string) {
const post = await db.posts.findUnique({
where: { slug },
select: {
id: true,
title: true,
content: true,
excerpt: true,
coverImage: true,
author: {
select: {
name: true,
},
},
publishedAt: true,
tags: true,
},
});
return post;
}
// ✅ Fetch only needed data
// ✅ Include author, tags for metadataimport { Metadata } from 'next';
import { getPost } from '@/app/lib/db';
import { notFound } from 'next/navigation';
interface Props {
params: { slug: string };
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug);
if (!post) {
return {
title: 'Post Not Found',
};
}
return {
title: post.title,
description: post.excerpt || post.content.substring(0, 160),
authors: [{ name: post.author.name }],
keywords: post.tags,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt.toISOString(),
authors: [post.author.name],
images: [
{
url: post.coverImage,
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
alternates: {
canonical: `https://myblog.com/blog/${params.slug}`,
},
};
}
export default async function BlogPostPage({ params }: Props) {
const post = await getPost(params.slug);
if (!post) {
notFound();
}
return (
<article>
<h1>{post.title}</h1>
<img src={post.coverImage} alt={post.title} />
<p>{post.content}</p>
</article>
);
}
// ✅ Complete metadata from database
// ✅ Handle not found case
// ✅ Open Graph for social media
// ✅ Twitter card
// ✅ Canonical URL with slugProduct Page Example
import { Metadata } from 'next';
interface Product {
id: string;
name: string;
description: string;
price: number;
images: string[];
category: string;
brand: string;
inStock: boolean;
}
async function getProduct(id: string): Promise<Product | null> {
const product = await db.products.findUnique({
where: { id },
});
return product;
}
export async function generateMetadata({
params
}: {
params: { id: string }
}): Promise<Metadata> {
const product = await getProduct(params.id);
if (!product) {
return { title: 'Product Not Found' };
}
return {
title: `${product.name} - ${product.brand}`,
description: product.description,
keywords: [
product.name,
product.brand,
product.category,
'buy online',
'shop',
],
openGraph: {
title: product.name,
description: product.description,
type: 'product',
images: product.images.map(img => ({
url: img,
width: 1200,
height: 630,
alt: product.name,
})),
// Product-specific Open Graph
...(product.price && {
'product:price:amount': product.price.toString(),
'product:price:currency': 'USD',
}),
...(product.inStock !== undefined && {
'product:availability': product.inStock ? 'in stock' : 'out of stock',
}),
},
twitter: {
card: 'summary_large_image',
title: product.name,
description: product.description,
images: [product.images[0]],
},
alternates: {
canonical: `https://mystore.com/products/${params.id}`,
},
// Structured data for rich snippets
other: {
'product:category': product.category,
'product:brand': product.brand,
},
};
}
export default async function ProductPage({
params
}: {
params: { id: string }
}) {
const product = await getProduct(params.id);
if (!product) {
notFound();
}
return (
<div>
<h1>{product.name}</h1>
<p>${product.price}</p>
<p>{product.description}</p>
</div>
);
}
// ✅ Product metadata from database
// ✅ Price and availability in Open Graph
// ✅ Multiple images
// ✅ Category and brand keywordsExtending Parent Metadata
Using Parent Metadata
import { Metadata, ResolvingMetadata } from 'next';
export async function generateMetadata(
{ params }: { params: { slug: string } },
parent: ResolvingMetadata
): Promise<Metadata> {
const post = await getPost(params.slug);
// Access parent metadata
const previousImages = (await parent).openGraph?.images || [];
return {
title: post.title,
openGraph: {
images: [
// Add new image first
post.coverImage,
// Keep parent images as fallback
...previousImages,
],
},
};
}
// ✅ Extend parent metadata
// ✅ Access parent Open Graph images
// ✅ Combine with new metadata
// ✅ Fallback to parentMerging with Parent
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const post = await getPost(params.slug);
const parentMetadata = await parent;
return {
title: post.title,
description: post.excerpt,
// Merge keywords
keywords: [
...(parentMetadata.keywords || []),
...post.tags,
],
// Extend Open Graph
openGraph: {
...parentMetadata.openGraph,
title: post.title,
description: post.excerpt,
type: 'article',
images: [post.coverImage],
},
};
}
// ✅ Merge parent keywords
// ✅ Extend Open Graph
// ✅ Override specific fields
// ✅ Keep parent defaultsUsing Search Parameters
Search Results Page
import { Metadata } from 'next';
interface Props {
searchParams: {
q?: string;
category?: string;
page?: string;
};
}
export async function generateMetadata({
searchParams
}: Props): Promise<Metadata> {
const query = searchParams.q || '';
const category = searchParams.category;
const page = searchParams.page || '1';
let title = 'Search Results';
let description = 'Search our site';
if (query) {
title = `Search results for "${query}"`;
description = `Find results for ${query} on our site`;
}
if (category) {
title += ` in ${category}`;
description += ` in ${category} category`;
}
if (page !== '1') {
title += ` - Page ${page}`;
}
return {
title,
description,
// Prevent indexing paginated pages
robots: {
index: page === '1',
follow: true,
},
// Canonical to page 1
alternates: {
canonical: `https://example.com/search?q=${query}${
category ? `&category=${category}` : ''
}`,
},
};
}
export default async function SearchPage({ searchParams }: Props) {
const query = searchParams.q || '';
const results = await searchDatabase(query);
return (
<div>
<h1>Search Results for "{query}"</h1>
{/* Results */}
</div>
);
}
// ✅ Dynamic title from search query
// ✅ Include category and page
// ✅ Prevent indexing pagination
// ✅ Canonical to page 1Category Filtering
export async function generateMetadata({
searchParams,
}: {
searchParams: { category?: string; sort?: string };
}): Promise<Metadata> {
const category = searchParams.category;
const sort = searchParams.sort;
if (!category) {
return {
title: 'All Products',
description: 'Browse all our products',
};
}
// Capitalize category for display
const categoryName = category.charAt(0).toUpperCase() + category.slice(1);
return {
title: `${categoryName} Products`,
description: `Shop our ${categoryName.toLowerCase()} products`,
keywords: [category, 'products', 'buy', 'shop'],
openGraph: {
title: `${categoryName} Products`,
description: `Shop our ${categoryName.toLowerCase()} products`,
},
// Only index default sort
robots: {
index: !sort || sort === 'default',
follow: true,
},
};
}
// ✅ Dynamic title per category
// ✅ Prevent indexing sort variations
// ✅ Category-specific keywordsError Handling in generateMetadata
Handling Missing Data
export async function generateMetadata({
params
}: Props): Promise<Metadata> {
try {
const post = await getPost(params.slug);
if (!post) {
// Return fallback metadata for 404
return {
title: 'Post Not Found',
description: 'The blog post you are looking for does not exist.',
robots: {
index: false,
follow: false,
},
};
}
return {
title: post.title,
description: post.excerpt,
// ... other metadata
};
} catch (error) {
console.error('Error generating metadata:', error);
// Fallback metadata on error
return {
title: 'Error Loading Post',
description: 'An error occurred while loading this post.',
robots: {
index: false,
follow: true,
},
};
}
}
// ✅ Handle not found
// ✅ Handle errors gracefully
// ✅ Prevent indexing error pages
// ✅ Return fallback metadataDefault Values and Fallbacks
export async function generateMetadata({
params
}: Props): Promise<Metadata> {
const post = await getPost(params.slug);
if (!post) {
return { title: 'Not Found' };
}
return {
// Use post title or fallback
title: post.title || 'Untitled Post',
// Use excerpt or truncated content
description:
post.excerpt ||
post.content?.substring(0, 160) ||
'Read this blog post',
// Use cover image or default
openGraph: {
images: [
post.coverImage || 'https://example.com/default-og.jpg',
],
},
// Safe array access
keywords: post.tags || [],
// Format date safely
...(post.publishedAt && {
openGraph: {
publishedTime: post.publishedAt.toISOString(),
},
}),
};
}
// ✅ Fallback for missing fields
// ✅ Default images
// ✅ Safe property access
// ✅ Conditional metadataPerformance Optimization
Request Deduplication
// Next.js automatically deduplicates identical requests
export async function generateMetadata({ params }: Props) {
// This request is cached and shared
const post = await fetch(`/api/posts/${params.slug}`);
return { title: post.title };
}
export default async function Page({ params }: Props) {
// Same request - uses cached result!
const post = await fetch(`/api/posts/${params.slug}`);
return <div>{post.content}</div>;
}
// ✅ Automatic request deduplication
// ✅ Same data fetched once
// ✅ No extra database queries
// ✅ Better performanceParallel Metadata Generation
export async function generateMetadata({ params }: Props) {
// Fetch multiple things in parallel
const [post, author, relatedPosts] = await Promise.all([
getPost(params.slug),
getAuthor(params.authorId),
getRelatedPosts(params.slug),
]);
return {
title: post.title,
authors: [{ name: author.name }],
description: post.excerpt,
// Use relatedPosts for keywords, etc.
};
}
// ✅ Parallel fetching
// ✅ Faster metadata generation
// ✅ Reduced wait timeCaching Strategy
// Cache metadata generation
export async function generateMetadata({ params }: Props) {
const post = await fetch(`/api/posts/${params.slug}`, {
next: {
revalidate: 3600, // Cache for 1 hour
tags: ['posts', `post-${params.slug}`],
},
}).then(res => res.json());
return {
title: post.title,
description: post.excerpt,
};
}
// ✅ Cache metadata fetches
// ✅ Revalidate periodically
// ✅ Tag-based invalidation
// ✅ Better performanceDynamic Metadata Structure
Organization of pages with generateMetadata
Select a file or folder to see details
Dynamic Metadata Best Practices
1. Always Handle Missing Data
// ✅ GOOD: Handle not found
export async function generateMetadata({ params }: Props) {
const post = await getPost(params.slug);
if (!post) {
return {
title: 'Not Found',
robots: { index: false },
};
}
return { title: post.title };
}
// ❌ BAD: Assumes data exists
export async function generateMetadata({ params }: Props) {
const post = await getPost(params.slug);
return { title: post.title }; // Crashes if post is null!
}2. Use Same Data Fetching as Page
// ✅ GOOD: Share fetching logic
async function getPost(slug: string) {
return await db.posts.findUnique({ where: { slug } });
}
export async function generateMetadata({ params }: Props) {
const post = await getPost(params.slug);
return { title: post.title };
}
export default async function Page({ params }: Props) {
const post = await getPost(params.slug);
return <div>{post.content}</div>;
}
// Requests automatically deduplicated3. Provide Meaningful Fallbacks
// ✅ GOOD: Meaningful fallbacks
return {
title: post.title || 'Untitled Post',
description: post.excerpt || 'Read this blog post',
openGraph: {
images: [post.image || '/default-og.jpg'],
},
};
// ❌ BAD: Empty or generic fallbacks
return {
title: post.title || 'Post',
description: post.excerpt || '',
};4. Don't Index Duplicate Content
// ✅ GOOD: Prevent indexing duplicates
export async function generateMetadata({ searchParams }: Props) {
const page = searchParams.page || '1';
return {
title: `Search Results - Page ${page}`,
robots: {
index: page === '1', // Only index first page
follow: true,
},
alternates: {
canonical: 'https://example.com/search', // Canonical to page 1
},
};
}5. Keep Metadata Generation Fast
// ✅ GOOD: Fast, focused queries
export async function generateMetadata({ params }: Props) {
const post = await db.posts.findUnique({
where: { slug: params.slug },
select: {
title: true,
excerpt: true,
coverImage: true,
// Only select what's needed
},
});
return { title: post.title };
}
// ❌ BAD: Slow, unfocused queries
export async function generateMetadata({ params }: Props) {
const post = await db.posts.findUnique({
where: { slug: params.slug },
include: {
author: true,
comments: true, // Not needed for metadata!
relatedPosts: true, // Not needed!
},
});
return { title: post.title };
}Key Takeaways
- generateMetadata - async function for dynamic metadata
- Route parameters - access via params
- Search parameters - access via searchParams
- Database content - fetch and use for metadata
- Parent metadata - extend with parent parameter
- Error handling - always handle missing data
- Request deduplication - automatic caching
- Type safety - full TypeScript support
What's Next?
You've mastered dynamic metadata generation! Next, we'll explore Open Graph and Social Media Cards—creating dynamic OG images, customizing social media previews, and building rich cards for Twitter, Facebook, and LinkedIn. You'll make your content shine when shared!
We'll cover dynamic OG image generation, social media best practices, preview debugging, and creating engaging social cards that drive clicks.
⚡ Performance Matters
generateMetadata runs for every page request in dynamic routes. Keep it fast! Fetch only needed fields, use caching, and leverage automatic request deduplication. Fast metadata generation means faster page loads.