Next.js Metadata API — SEO, Open Graph, and Dynamic Meta Tags
Advertisement
Introduction
The Next.js Metadata API is the App Router's built-in solution for managing title, description, Open Graph tags, Twitter cards, canonical URLs, and more. Exporting a metadata object or a generateMetadata function from any page.tsx or layout.tsx automatically injects the correct <head> tags. This replaces next/head from the Pages Router and integrates directly with Server Components.
Why This Matters
Title and meta description tags are still the most direct influence you have over click-through rates from Google search results. Open Graph tags control how your pages appear when shared on LinkedIn, Twitter, Facebook, and messaging apps. Missing or incorrect metadata is a guaranteed way to lose organic traffic.
The Metadata API makes SEO systematic: define base metadata in the root layout, override per-page with static exports, and dynamically generate tags for content pages using generateMetadata. The API handles tag deduplication, cascading, and streaming automatically.
Static Metadata
Export a metadata object from any page.tsx or layout.tsx:
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
default: 'My App',
template: '%s | My App', // Page titles become "About | My App"
},
description: 'The best app for managing your workflow.',
keywords: ['productivity', 'workflow', 'project management'],
authors: [{ name: 'Sanjeev Sharma', url: 'https://webcoderspeed.com' }],
creator: 'Sanjeev Sharma',
metadataBase: new URL('https://webcoderspeed.com'),
openGraph: {
type: 'website',
locale: 'en_US',
url: 'https://webcoderspeed.com',
siteName: 'WebCoderSpeed',
},
twitter: {
card: 'summary_large_image',
creator: '@webcoderspeed',
},
robots: {
index: true,
follow: true,
},
}Child pages that export their own title will use the template: 'About' → 'About | My App'.
Per-Page Static Metadata
Override metadata for specific pages:
// app/about/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'About',
description: 'Learn about our team and mission at WebCoderSpeed.',
openGraph: {
title: 'About WebCoderSpeed',
description: 'Learn about our team and mission.',
images: [{ url: '/og/about.png', width: 1200, height: 630 }],
},
}
export default function AboutPage() {
return <main>About page content</main>
}Dynamic Metadata with generateMetadata
For content pages where metadata depends on fetched data:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
interface Props {
params: { slug: string }
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await fetch(`https://cms.example.com/posts/${params.slug}`, {
next: { revalidate: 3600 },
}).then((r) => r.json())
if (!post) {
return {
title: 'Post Not Found',
description: 'The requested post could not be found.',
}
}
return {
title: post.title,
description: post.excerpt,
authors: [{ name: post.author.name }],
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt,
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],
},
}
}
export default async function BlogPostPage({ params }: Props) {
const post = await fetch(`https://cms.example.com/posts/${params.slug}`).then((r) => r.json())
return <article>{post.content}</article>
}Next.js deduplicates the fetch — generateMetadata and the page component share the same cached response.
Open Graph Image Generation
Generate dynamic OG images with ImageResponse from next/og:
// app/og/route.tsx
import { ImageResponse } from 'next/og'
import { NextRequest } from 'next/server'
export const runtime = 'edge'
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url)
const title = searchParams.get('title') ?? 'My Site'
const description = searchParams.get('description') ?? ''
return new ImageResponse(
(
<div
style={{
background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: '60px',
color: 'white',
fontFamily: 'sans-serif',
}}
>
<p style={{ fontSize: 24, color: '#60a5fa', margin: '0 0 16px' }}>WebCoderSpeed</p>
<h1 style={{ fontSize: 56, margin: '0 0 16px', lineHeight: 1.1 }}>{title}</h1>
<p style={{ fontSize: 28, color: '#94a3b8', margin: 0 }}>{description}</p>
</div>
),
{ width: 1200, height: 630 }
)
}Then reference it in generateMetadata:
openGraph: {
images: [`/og?title=${encodeURIComponent(post.title)}&description=${encodeURIComponent(post.excerpt)}`],
}Canonical URLs
Set canonical URLs to prevent duplicate content penalties:
export const metadata: Metadata = {
metadataBase: new URL('https://webcoderspeed.com'),
alternates: {
canonical: '/blog/my-post-slug',
languages: {
'en-US': '/en/blog/my-post-slug',
'es-ES': '/es/blog/my-post-slug',
},
},
}Robots and Indexing Control
Control crawler behavior per page:
// Block indexing on staging or private pages
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
googleBot: {
index: false,
follow: false,
},
},
}
// Explicitly allow indexing (default)
export const metadata: Metadata = {
robots: {
index: true,
follow: true,
'max-image-preview': 'large',
'max-snippet': -1,
'max-video-preview': -1,
},
}Sitemap and robots.txt
Generate these automatically with Next.js file conventions:
// app/sitemap.ts
import { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await fetch('https://cms.example.com/posts').then((r) => r.json())
return [
{ url: 'https://webcoderspeed.com', lastModified: new Date(), priority: 1 },
{ url: 'https://webcoderspeed.com/blog', lastModified: new Date(), priority: 0.8 },
...posts.map((post: { slug: string; updatedAt: string }) => ({
url: `https://webcoderspeed.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
priority: 0.6,
})),
]
}// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: '/private/' },
sitemap: 'https://webcoderspeed.com/sitemap.xml',
}
}Common Mistakes
- Not setting
metadataBase— relative URLs inopenGraph.imagesbecome broken links - Using duplicate
titlein bothlayout.tsxandpage.tsxinstead of using the template pattern - Forgetting to add
generateMetadatato dynamic content pages — they get the layout's generic title - Setting
robots: { index: false }on production pages accidentally - Not providing OG image dimensions — social crawlers may reject or poorly display the image
Best Practices
- Set
metadataBasein root layout to ensure all relative URLs resolve correctly - Use the
title.templatepattern in root layout so every page title includes the site name - Always export
generateMetadatafor dynamic content pages (blog posts, products, etc.) - Generate dynamic OG images with
ImageResponsefor better social sharing appearance - Include
publishedTimeandmodifiedTimein article Open Graph tags for Google Discover - Add
alternates.canonicalto prevent duplicate content when content appears at multiple URLs
Key Takeaways
- The Metadata API replaces
next/head— export ametadataobject orgenerateMetadatafunction from page files title.template: '%s | Site Name'in root layout automatically formats child page titlesgenerateMetadatareceives the sameparamsas the page component and canawaitasync data- Next.js deduplicates
fetchcalls betweengenerateMetadataand the page component metadataBasemust be set in root layout for relative OG image URLs to resolve correctlyImageResponsefromnext/oggenerates dynamic social images using JSX on the Edge Runtimeapp/sitemap.tsandapp/robots.tsgenerate those files automatically at build time- Open Graph images should be 1200x630 pixels — the ideal size for most social platforms
Advertisement