Next.js Metadata API — SEO, Open Graph, and Dynamic Meta Tags

Sanjeev SharmaSanjeev Sharma
6 min read

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 in openGraph.images become broken links
  • Using duplicate title in both layout.tsx and page.tsx instead of using the template pattern
  • Forgetting to add generateMetadata to 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 metadataBase in root layout to ensure all relative URLs resolve correctly
  • Use the title.template pattern in root layout so every page title includes the site name
  • Always export generateMetadata for dynamic content pages (blog posts, products, etc.)
  • Generate dynamic OG images with ImageResponse for better social sharing appearance
  • Include publishedTime and modifiedTime in article Open Graph tags for Google Discover
  • Add alternates.canonical to prevent duplicate content when content appears at multiple URLs

Key Takeaways

  • The Metadata API replaces next/head — export a metadata object or generateMetadata function from page files
  • title.template: '%s | Site Name' in root layout automatically formats child page titles
  • generateMetadata receives the same params as the page component and can await async data
  • Next.js deduplicates fetch calls between generateMetadata and the page component
  • metadataBase must be set in root layout for relative OG image URLs to resolve correctly
  • ImageResponse from next/og generates dynamic social images using JSX on the Edge Runtime
  • app/sitemap.ts and app/robots.ts generate those files automatically at build time
  • Open Graph images should be 1200x630 pixels — the ideal size for most social platforms

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading