Static metadata in Next.js is straightforward — export a metadata object from a route file. Dynamic metadata, where the title and description depend on the page content, requires generateMetadata(). This covers the complete pattern: per-page titles and descriptions, Open Graph and Twitter card generation, and dynamic OG images.
These patterns form the SEO infrastructure for content-heavy sites — the approach used across all 60+ blog posts at content generation pipeline.
Static Metadata
For static routes, export a metadata object:
// app/about/page.tsx
import { Metadata } from 'next';
export const metadata: Metadata = {
title: 'About Us',
description: 'Learn about our team and mission.',
openGraph: {
title: 'About Us',
description: 'Learn about our team and mission.',
url: 'https://example.com/about',
},
};
export default function AboutPage() {
return <main>About content</main>;
}
Dynamic Metadata with generateMetadata()
For dynamic routes where title and description depend on fetched data:
// app/blog/[slug]/page.tsx
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
type 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,
openGraph: {
title: post.title,
description: post.excerpt,
url: `https://example.com/blog/${params.slug}`,
type: 'article',
publishedTime: post.publishedAt,
authors: [post.authorName],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
},
};
}
export default async function BlogPost({ params }: Props) {
const post = await getPost(params.slug);
if (!post) notFound();
return <article>{/* post content */}</article>;
}
Next.js deduplicates the getPost call — generateMetadata and the page component can both call it without making two network requests.
Open Graph Images
The openGraph.images field accepts URLs for static OG images or the Next.js image generation API for dynamic ones.
Static OG images — same image for every page of a type:
export const metadata: Metadata = {
openGraph: {
images: [
{
url: '/og/blog-default.jpg',
width: 1200,
height: 630,
alt: 'Blog post default image',
},
],
},
};
Dynamic OG images — unique image for each post using the ImageResponse API:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
type Props = {
params: { slug: string };
};
export default async function OGImage({ params }: Props) {
const post = await getPost(params.slug);
return new ImageResponse(
(
<div
style={{
background: '#0f172a',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: '64px',
fontFamily: 'system-ui',
}}
>
<p
style={{
fontSize: '24px',
color: '#94a3b8',
margin: '0 0 16px',
}}
>
{post?.category ?? 'Blog'}
</p>
<h1
style={{
fontSize: '64px',
fontWeight: 700,
color: '#f8fafc',
margin: '0',
lineHeight: 1.1,
maxWidth: '900px',
}}
>
{post?.title ?? 'Untitled Post'}
</h1>
</div>
),
{ ...size }
);
}
Place this file at app/blog/[slug]/opengraph-image.tsx. Next.js automatically serves it at /blog/[slug]/opengraph-image and links it in the page's metadata.
Metadata Inheritance and the Layout Hierarchy
generateMetadata in nested layouts merges with parent metadata. Define defaults at the root layout level:
// app/layout.tsx
export const metadata: Metadata = {
title: {
default: 'My Site',
template: '%s | My Site',
},
description: 'Default site description',
openGraph: {
siteName: 'My Site',
type: 'website',
},
};
With template: '%s | My Site', a page that exports title: 'Blog Post Title' renders as Blog Post Title | My Site in the browser tab. The default value applies when no child provides a title.
Structured Data for Blog Posts
Include JSON-LD in page components for rich results:
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
const post = await getPost(params.slug);
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.title,
description: post.excerpt,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.authorName,
},
image: post.coverImage,
url: `https://example.com/blog/${params.slug}`,
};
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>
{/* post content */}
</article>
</>
);
}
Canonical URLs
Set canonical URLs explicitly to avoid duplicate content issues, especially for paginated content or content syndicated from external sources:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug);
return {
title: post.title,
alternates: {
canonical: `https://example.com/blog/${params.slug}`,
},
};
}
This is important for blogs where the same content might be accessible at multiple URLs (with/without trailing slash, different query parameters, etc.).
Robots Meta
Control indexing per-page:
export const metadata: Metadata = {
robots: {
index: true,
follow: true,
'max-snippet': -1,
'max-image-preview': 'large',
'max-video-preview': -1,
},
};
For pages you don't want indexed:
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
},
};
Summary
Dynamic metadata flows from generateMetadata() returning a Metadata object with per-page title, description, OG, and Twitter values. OG images can be static (a single image per route type) or dynamic using the ImageResponse API in a colocated opengraph-image.tsx file. Title templates in the root layout handle the global brand suffix. JSON-LD and canonical URLs complete the SEO infrastructure.
Verifying Metadata in Production
After deploying, verify metadata appears correctly:
Browser DevTools: Right-click → View Page Source → search for <meta name="description", <meta property="og:title".
Open Graph Debugger: Use developers.facebook.com/tools/debug to preview how a URL appears when shared. Clears Facebook's cache when you submit.
Twitter Card Validator: cards-dev.twitter.com/validator previews Twitter card appearance.
Google Rich Results Test: search.google.com/test/rich-results validates structured data and previews rich results eligibility.
These tools catch issues before they affect actual shares and search appearances — a mistyped property name or missing field doesn't surface as an error in development but shows as missing data when someone shares the URL.
Metadata Gotchas
Title length. Google typically shows up to 60 characters. Titles longer than this get truncated in search results. With the template suffix adding | Site Name, the base title needs to be even shorter.
Description length. Meta descriptions at 150-160 characters display fully in search results. Longer descriptions get cut off.
OG image dimensions. 1200 × 630 pixels is the standard for most social platforms. Images outside this ratio display incorrectly on some platforms — the ImageResponse API defaults to these dimensions if you don't specify otherwise.
Missing fallbacks. generateMetadata can return partial metadata — if a post isn't found, returning { title: 'Not Found' } is fine. The page's notFound() call handles the 404 response separately from the metadata.
Data deduplication. Next.js automatically deduplicates fetch calls with the same URL and options — generateMetadata and the page component can both call getPost(slug) without making two database/network requests. This requires using fetch with Next.js's cache rather than a direct database call, or implementing your own deduplication with React's cache() function.
Using cache() for Data Deduplication Without fetch
For direct database queries that don't go through fetch, use React's cache() to deduplicate:
// lib/posts.ts
import { cache } from 'react';
export const getPost = cache(async (slug: string) => {
// Direct database query — no fetch involved
return db.posts.findUnique({ where: { slug } });
});
Wrap database functions in cache() and both generateMetadata and the page component call the same cached function. The database query runs once per request regardless of how many places call getPost(slug).
Top comments (0)