NextJS DOC
Part 13 of 151. Next.js Project Structure: A Comprehensive Architecture Guide2. Next.js Layouts and Pages: Complete Architecture Guide3. Next.js Linking and Navigation: Complete Architecture Guide4. Next.js Server and Client Components: Complete Architecture Guide5. Next.js Data Fetching: Complete Architecture Guide6. Next.js Data Mutation: Complete Server Actions Guide7. Next.js Caching Deep Dive: Cache Components and the `use cache` Directive8. Next.js Revalidation Deep Dive: Time-Based and On-Demand Cache Invalidation9. Next.js Error Handling Deep Dive: Expected Errors, Uncaught Exceptions, and Recovery Patterns10. Next.js CSS Deep Dive: Tailwind, CSS Modules, Sass, and CSS-in-JS11. Next.js Image Optimization Deep Dive: Performance, Responsive Images, and Configuration12. Next.js Font Optimization Deep Dive: Self-Hosted Fonts, CSS Variables, and CLS Prevention13. Next.js Metadata and OG Images Deep Dive: SEO, Social Sharing, and Dynamic Generation14. Next.js Route Handlers Deep Dive: Building Production APIs with Web Standards15. Next.js Proxy Deep Dive: Edge-First Request Interception
Next.js Metadata and OG Images Deep Dive: SEO, Social Sharing, and Dynamic Generation
April 4, 202691 min read0 views
Next.js Metadata and OG Images Deep Dive: SEO, Social Sharing, and Dynamic Generation
Introduction
Next.js provides a comprehensive Metadata API for defining page metadata (titles, descriptions, Open Graph images) that improves SEO and social media shareability. This guide covers static and dynamic metadata, file-based conventions, dynamic OG image generation, and production patterns for maximizing discoverability.
Metadata Architecture Overview
┌─────────────────────────────────────────────────────────────────────────────┐
│ NEXT.JS METADATA ARCHITECTURE │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ METADATA SOURCES │ │
│ │ │ │
│ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────────┐ │ │
│ │ │ Config-based │ │ File-based │ │ Dynamic │ │ │
│ │ │ │ │ │ │ (generateMetadata) │ │ │
│ │ │ metadata = { │ │ favicon.ico │ │ │ │ │
│ │ │ title: ... │ │ og-image.jpg │ │ async function(params) { │ │ │
│ │ │ } │ │ robots.txt │ │ const data = await ... │ │ │
│ │ │ │ │ sitemap.xml │ │ return { title: ... } │ │ │
│ │ └──────────────┘ └──────────────┘ └──────────────────────────┘ │ │
│ │ │ │
│ │ Priority: File-based > Dynamic > Static config │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ METADATA MERGING │ │
│ │ │ │
│ │ app/layout.tsx { title: { template: '%s | Acme' } } │ │
│ │ ↓ │ │
│ │ app/blog/layout { title: { default: 'Blog' } } │ │
│ │ ↓ │ │
│ │ app/blog/[slug] { title: 'My Post' } │ │
│ │ ↓ │ │
│ │ Final Output: <title>My Post | Acme</title> │ │
│ │ │ │
│ │ MERGING RULES: │ │
│ │ • Root → Nested → Page (evaluation order) │ │
│ │ • Shallow merge (nested objects replaced, not deep merged) │ │
│ │ • Later definitions override earlier ones │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────────┐ │
│ │ OUTPUT GENERATION │ │
│ │ │ │
│ │ ┌──────────────────────────────────────────────────────────────┐ │ │
│ │ │ <head> │ │ │
│ │ │ <title>My Post | Acme</title> │ │ │
│ │ │ <meta name="description" content="..." /> │ │ │
│ │ │ <meta property="og:title" content="My Post" /> │ │ │
│ │ │ <meta property="og:image" content="/blog/my-post/og.png"/> │ │ │
│ │ │ <meta name="twitter:card" content="summary_large_image" /> │ │ │
│ │ │ <link rel="canonical" href="https://acme.com/blog/my-post"/>│ │ │
│ │ │ </head> │ │ │
│ │ └──────────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Static Metadata
Basic Usage
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Acme Inc',
description: 'Building the future of commerce',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
Title Templates
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
template: '%s | Acme', // %s replaced by child title
default: 'Acme', // Fallback if no child title
},
description: 'Building the future of commerce',
}
// app/blog/page.tsx
export const metadata: Metadata = {
title: 'Blog', // Results in: "Blog | Acme"
}
// app/about/page.tsx
export const metadata: Metadata = {
title: {
absolute: 'About Us', // Ignores template: "About Us"
},
}
metadataBase for URL Resolution
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://acme.com'),
// Now relative URLs work throughout
openGraph: {
images: '/og-image.png', // Resolves to https://acme.com/og-image.png
},
alternates: {
canonical: '/about', // Resolves to https://acme.com/about
},
}
Complete Metadata Object
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
// Base URL for relative paths
metadataBase: new URL('https://acme.com'),
// Basic metadata
title: {
template: '%s | Acme',
default: 'Acme - Future of Commerce',
},
description: 'Building the future of commerce with AI-powered solutions.',
keywords: ['commerce', 'AI', 'e-commerce', 'platform'],
authors: [{ name: 'Acme Team', url: 'https://acme.com/team' }],
creator: 'Acme Inc',
publisher: 'Acme Inc',
// Application metadata
applicationName: 'Acme Platform',
generator: 'Next.js',
referrer: 'origin-when-cross-origin',
// Format detection
formatDetection: {
email: false,
address: false,
telephone: false,
},
// Open Graph
openGraph: {
title: 'Acme - Future of Commerce',
description: 'Building the future of commerce with AI-powered solutions.',
url: 'https://acme.com',
siteName: 'Acme',
images: [
{
url: '/og-image.png',
width: 1200,
height: 630,
alt: 'Acme Platform',
},
],
locale: 'en_US',
type: 'website',
},
// Twitter Card
twitter: {
card: 'summary_large_image',
title: 'Acme - Future of Commerce',
description: 'Building the future of commerce with AI-powered solutions.',
creator: '@acmeinc',
images: ['/twitter-image.png'],
},
// Robots
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
// Icons
icons: {
icon: '/favicon.ico',
shortcut: '/favicon-16x16.png',
apple: '/apple-touch-icon.png',
},
// Canonical and alternates
alternates: {
canonical: '/',
languages: {
'en-US': '/en-US',
'de-DE': '/de-DE',
},
},
// Verification
verification: {
google: 'google-verification-code',
yandex: 'yandex-verification-code',
},
// Manifest
manifest: '/manifest.json',
// Category
category: 'technology',
}
Dynamic Metadata with generateMetadata
Basic Usage
// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'
interface Props {
params: Promise<{ slug: string }>
searchParams: Promise<{ [key: string]: string | string[] | undefined }>
}
export async function generateMetadata(
{ params, searchParams }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const { slug } = await params
// Fetch post data
const post = await fetch(`https://api.acme.com/posts/${slug}`).then(res =>
res.json()
)
// Access parent metadata
const previousImages = (await parent).openGraph?.images || []
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [
{
url: post.coverImage,
width: 1200,
height: 630,
},
...previousImages,
],
},
}
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const post = await fetch(`https://api.acme.com/posts/${slug}`).then(res =>
res.json()
)
return <article>{/* ... */}</article>
}
Memoizing Data Requests
// lib/data.ts
import { cache } from 'react'
// Single fetch used by both generateMetadata and Page
export const getPost = cache(async (slug: string) => {
const res = await fetch(`https://api.acme.com/posts/${slug}`)
return res.json()
})
// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/data'
import type { Metadata } from 'next'
interface Props {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug) // Memoized
return {
title: post.title,
description: post.excerpt,
}
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const post = await getPost(slug) // Returns cached result
return <article>{post.content}</article>
}
Extending Parent Metadata
// app/products/[id]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'
export async function generateMetadata(
{ params }: { params: Promise<{ id: string }> },
parent: ResolvingMetadata
): Promise<Metadata> {
const { id } = await params
const product = await getProduct(id)
// Get parent metadata
const parentMetadata = await parent
// Extend parent's OpenGraph images
const previousImages = parentMetadata.openGraph?.images || []
return {
title: product.name,
description: product.description,
openGraph: {
...parentMetadata.openGraph,
title: product.name,
description: product.description,
images: [
{
url: product.image,
width: 1200,
height: 630,
alt: product.name,
},
...previousImages,
],
},
}
}
File-Based Metadata
Supported Files
app/
├── favicon.ico # Site favicon
├── icon.png # App icon (also icon.svg, icon.ico)
├── icon.tsx # Generated icon
├── apple-icon.png # Apple touch icon
├── opengraph-image.png # Default OG image
├── opengraph-image.tsx # Generated OG image
├── twitter-image.png # Twitter card image
├── twitter-image.tsx # Generated Twitter image
├── robots.txt # Robots file
├── robots.ts # Generated robots
├── sitemap.xml # Sitemap
├── sitemap.ts # Generated sitemap
└── manifest.json # Web app manifest
Static OG Images
Place opengraph-image.png (or .jpg, .gif) in any route folder:
app/
├── opengraph-image.png # Default for all routes
├── blog/
│ ├── opengraph-image.png # Overrides for /blog/*
│ └── [slug]/
│ └── opengraph-image.png # Specific to each post
Generated OG Images
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/data'
// Image metadata
export const size = {
width: 1200,
height: 630,
}
export const contentType = 'image/png'
// Optional: Generate for specific routes
export async function generateStaticParams() {
const posts = await getPosts()
return posts.map((post) => ({ slug: post.slug }))
}
export default async function Image({
params,
}: {
params: { slug: string }
}) {
const post = await getPost(params.slug)
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: 60,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
marginBottom: 40,
}}
>
<img
src="https://acme.com/logo.png"
width={60}
height={60}
style={{ marginRight: 20 }}
/>
<span
style={{
fontSize: 32,
color: 'white',
fontWeight: 'bold',
}}
>
Acme Blog
</span>
</div>
<h1
style={{
fontSize: 64,
fontWeight: 'bold',
color: 'white',
textAlign: 'center',
lineHeight: 1.2,
maxWidth: 900,
}}
>
{post.title}
</h1>
<div
style={{
display: 'flex',
alignItems: 'center',
marginTop: 40,
color: 'rgba(255, 255, 255, 0.8)',
fontSize: 24,
}}
>
<span>{post.author.name}</span>
<span style={{ margin: '0 20px' }}>•</span>
<span>{post.readingTime} min read</span>
</div>
</div>
),
{
...size,
}
)
}
Custom Fonts in OG Images
// app/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export default async function Image() {
// Load custom font
const interBold = fetch(
new URL('./fonts/Inter-Bold.ttf', import.meta.url)
).then((res) => res.arrayBuffer())
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000',
}}
>
<h1
style={{
fontSize: 80,
color: '#fff',
fontFamily: 'Inter',
}}
>
Hello World
</h1>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: await interBold,
style: 'normal',
weight: 700,
},
],
}
)
}
Dynamic Sitemap
// app/sitemap.ts
import { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = 'https://acme.com'
// Get dynamic routes
const posts = await fetch('https://api.acme.com/posts').then(res =>
res.json()
)
const postUrls = posts.map((post: { slug: string; updatedAt: string }) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: new Date(post.updatedAt),
changeFrequency: 'weekly' as const,
priority: 0.7,
}))
const products = await fetch('https://api.acme.com/products').then(res =>
res.json()
)
const productUrls = products.map((product: { id: string; updatedAt: string }) => ({
url: `${baseUrl}/products/${product.id}`,
lastModified: new Date(product.updatedAt),
changeFrequency: 'daily' as const,
priority: 0.8,
}))
return [
{
url: baseUrl,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1,
},
{
url: `${baseUrl}/about`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.5,
},
...postUrls,
...productUrls,
]
}
Dynamic Robots
// app/robots.ts
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', '/private/'],
},
{
userAgent: 'Googlebot',
allow: '/',
disallow: '/admin/',
},
],
sitemap: 'https://acme.com/sitemap.xml',
host: 'https://acme.com',
}
}
Production Patterns
Pattern 1: E-Commerce Product Pages
// app/products/[id]/page.tsx
import type { Metadata } from 'next'
import { cache } from 'react'
const getProduct = cache(async (id: string) => {
return fetch(`https://api.acme.com/products/${id}`).then(res => res.json())
})
export async function generateMetadata({
params,
}: {
params: Promise<{ id: string }>
}): Promise<Metadata> {
const { id } = await params
const product = await getProduct(id)
const structuredData = {
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.images[0],
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'USD',
availability: product.inStock
? 'https://schema.org/InStock'
: 'https://schema.org/OutOfStock',
},
}
return {
title: `${product.name} | Acme Shop`,
description: product.description,
openGraph: {
title: product.name,
description: product.description,
type: 'website',
images: product.images.map((img: string) => ({
url: img,
width: 1200,
height: 630,
})),
},
twitter: {
card: 'summary_large_image',
title: product.name,
description: product.description,
images: [product.images[0]],
},
other: {
'product:price:amount': product.price.toString(),
'product:price:currency': 'USD',
},
}
}
export default async function ProductPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const product = await getProduct(id)
return (
<>
{/* Structured data */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'Product',
name: product.name,
description: product.description,
image: product.images[0],
offers: {
'@type': 'Offer',
price: product.price,
priceCurrency: 'USD',
},
}),
}}
/>
{/* Product content */}
</>
)
}
Pattern 2: Blog with Categories
// app/blog/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
template: '%s | Acme Blog',
default: 'Blog | Acme',
},
description: 'Insights and tutorials from the Acme team',
openGraph: {
type: 'website',
siteName: 'Acme Blog',
},
}
// app/blog/[category]/page.tsx
import type { Metadata } from 'next'
const categories = {
engineering: {
title: 'Engineering',
description: 'Deep dives into our technical architecture',
},
design: {
title: 'Design',
description: 'Our approach to product design',
},
culture: {
title: 'Culture',
description: 'Life at Acme',
},
}
export async function generateMetadata({
params,
}: {
params: Promise<{ category: string }>
}): Promise<Metadata> {
const { category } = await params
const cat = categories[category as keyof typeof categories]
if (!cat) {
return { title: 'Category Not Found' }
}
return {
title: cat.title,
description: cat.description,
openGraph: {
title: `${cat.title} | Acme Blog`,
description: cat.description,
},
}
}
// app/blog/[category]/[slug]/page.tsx
import type { Metadata } from 'next'
import { getPost } from '@/lib/data'
export async function generateMetadata({
params,
}: {
params: Promise<{ category: string; slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
return {
title: post.title,
description: post.excerpt,
authors: [{ name: post.author.name }],
openGraph: {
type: 'article',
title: post.title,
description: post.excerpt,
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
tags: post.tags,
},
}
}
Pattern 3: Internationalized Metadata
// lib/dictionaries.ts
const dictionaries = {
en: () => import('./dictionaries/en.json').then(m => m.default),
de: () => import('./dictionaries/de.json').then(m => m.default),
}
export const getDictionary = async (locale: 'en' | 'de') =>
dictionaries[locale]()
// app/[lang]/layout.tsx
import type { Metadata } from 'next'
import { getDictionary } from '@/lib/dictionaries'
export async function generateMetadata({
params,
}: {
params: Promise<{ lang: 'en' | 'de' }>
}): Promise<Metadata> {
const { lang } = await params
const dict = await getDictionary(lang)
return {
title: {
template: `%s | ${dict.siteName}`,
default: dict.siteName,
},
description: dict.siteDescription,
alternates: {
canonical: `https://acme.com/${lang}`,
languages: {
'en': 'https://acme.com/en',
'de': 'https://acme.com/de',
},
},
openGraph: {
locale: lang === 'en' ? 'en_US' : 'de_DE',
alternateLocale: lang === 'en' ? ['de_DE'] : ['en_US'],
},
}
}
Pattern 4: Shared OG Image Template
// lib/og.tsx
import { ImageResponse } from 'next/og'
interface OGImageProps {
title: string
subtitle?: string
logo?: string
backgroundGradient?: string
}
export function generateOGImage({
title,
subtitle,
logo = 'https://acme.com/logo.png',
backgroundGradient = 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}: OGImageProps) {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'space-between',
background: backgroundGradient,
padding: 80,
}}
>
<img src={logo} width={80} height={80} />
<div
style={{
display: 'flex',
flexDirection: 'column',
}}
>
<h1
style={{
fontSize: 72,
fontWeight: 'bold',
color: 'white',
lineHeight: 1.1,
marginBottom: subtitle ? 20 : 0,
}}
>
{title}
</h1>
{subtitle && (
<p
style={{
fontSize: 36,
color: 'rgba(255, 255, 255, 0.8)',
}}
>
{subtitle}
</p>
)}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
color: 'rgba(255, 255, 255, 0.6)',
fontSize: 24,
}}
>
acme.com
</div>
</div>
),
{
width: 1200,
height: 630,
}
)
}
// app/blog/[slug]/opengraph-image.tsx
import { generateOGImage } from '@/lib/og'
import { getPost } from '@/lib/data'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image({
params,
}: {
params: { slug: string }
}) {
const post = await getPost(params.slug)
return generateOGImage({
title: post.title,
subtitle: `${post.readingTime} min read`,
backgroundGradient: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)',
})
}
Streaming Metadata
┌─────────────────────────────────────────────────────────────────────────────┐
│ METADATA STREAMING BEHAVIOR │
├─────────────────────────────────────────────────────────────────────────────┤
│ │
│ REGULAR USERS (JavaScript-capable): │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 1. Server sends initial HTML with UI immediately │ │
│ │ 2. generateMetadata resolves asynchronously │ │
│ │ 3. Metadata tags injected into DOM via streaming │ │
│ │ 4. User sees content faster (improved perceived performance) │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ HTML-LIMITED BOTS (Twitterbot, Slackbot, etc.): │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ 1. Next.js detects bot via User-Agent header │ │
│ │ 2. Metadata BLOCKS page rendering │ │
│ │ 3. Full metadata in <head> before any content │ │
│ │ 4. Ensures social previews work correctly │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
│ CONFIGURATION: │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ // Customize bot detection │ │
│ │ // next.config.ts │ │
│ │ htmlLimitedBots: /facebookexternalhit|Twitterbot|LinkedInBot/, │ │
│ │ │ │
│ │ // Disable streaming entirely │ │
│ │ htmlLimitedBots: /.*/, │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
Key Takeaways
- Use metadataBase: Set in root layout for correct URL resolution throughout
- Title Templates: Define in layouts with
%splaceholder for consistent branding - File-Based Has Priority:
opengraph-image.pngoverrides metadata config - Memoize Data Requests: Use
cache()when same data needed in generateMetadata and page - Dynamic OG Images: Use
ImageResponsefromnext/ogfor personalized social previews - Shallow Merging: Nested objects (openGraph, twitter) are replaced, not deep merged
- Streaming Metadata: Enabled by default for users, blocked for social bots
- Structured Data: Add JSON-LD scripts in page components for rich search results
- Generate Sitemaps Dynamically: Use
sitemap.tsfor auto-updating sitemaps - Test Social Previews: Use Facebook Debugger, Twitter Card Validator to verify
References
What did you think?