Back to Blog

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

  1. Use metadataBase: Set in root layout for correct URL resolution throughout
  2. Title Templates: Define in layouts with %s placeholder for consistent branding
  3. File-Based Has Priority: opengraph-image.png overrides metadata config
  4. Memoize Data Requests: Use cache() when same data needed in generateMetadata and page
  5. Dynamic OG Images: Use ImageResponse from next/og for personalized social previews
  6. Shallow Merging: Nested objects (openGraph, twitter) are replaced, not deep merged
  7. Streaming Metadata: Enabled by default for users, blocked for social bots
  8. Structured Data: Add JSON-LD scripts in page components for rich search results
  9. Generate Sitemaps Dynamically: Use sitemap.ts for auto-updating sitemaps
  10. Test Social Previews: Use Facebook Debugger, Twitter Card Validator to verify

References

What did you think?

© 2026 Vidhya Sagar Thakur. All rights reserved.