React SEO with Next.js: Metadata API and Sitemaps (2026)

Next.js App Router provides a first-class Metadata API that replaces the old next/head approach. You export a static metadata object or an async generateMetadata function from any layout or page file — Next.js merges them hierarchically and injects the correct HTML tags automatically. Combined with built-in sitemap and robots.txt support, Next.js gives you everything needed for production SEO.

Static Metadata

// app/layout.tsx — base metadata for the whole site
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: {
    default: 'Acme — Modern SaaS Platform',
    template: '%s | Acme',    // Used by child pages: "Blog | Acme"
  },
  description: 'The fastest way to build and deploy modern applications.',
  keywords: ['saas', 'platform', 'cloud', 'devops'],
  authors: [{ name: 'Acme Team', url: 'https://acme.com' }],
  creator: 'Acme Inc.',
  metadataBase: new URL('https://acme.com'),  // Required for absolute OG URLs
  alternates: {
    canonical: '/',
    languages: {
      'en-US': '/en-US',
      'de-DE': '/de-DE',
    },
  },
}

// app/blog/page.tsx — overrides title, inherits rest
export const metadata: Metadata = {
  title: 'Blog',                // Becomes "Blog | Acme" via template
  description: 'Latest articles from the Acme team.',
}

Dynamic generateMetadata

// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'

type Props = {
  params: { slug: string }
}

// Runs on the server — can fetch data
export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const post = await fetchPost(params.slug)
  const previousOgImages = (await parent).openGraph?.images || []

  if (!post) return { title: 'Post Not Found' }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.publishedAt,
      authors: [post.author.name],
      images: [
        {
          url: post.heroImage,
          width: 1200,
          height: 630,
          alt: post.title,
        },
        ...previousOgImages,
      ],
    },
    alternates: {
      canonical: `/blog/${params.slug}`,
    },
  }
}

Open Graph and Twitter Cards

export const metadata: Metadata = {
  openGraph: {
    title: 'Acme — Modern SaaS Platform',
    description: 'The fastest way to build modern apps.',
    url: 'https://acme.com',
    siteName: 'Acme',
    images: [
      {
        url: 'https://acme.com/og.png',
        width: 1200,
        height: 630,
        alt: 'Acme Platform',
      },
    ],
    locale: 'en_US',
    type: 'website',
  },

  twitter: {
    card: 'summary_large_image',
    title: 'Acme — Modern SaaS Platform',
    description: 'The fastest way to build modern apps.',
    site: '@acmehq',
    creator: '@acmehq',
    images: ['https://acme.com/og.png'],
  },
}

Structured Data (JSON-LD)

// Inject JSON-LD via a script tag in Server Components
export default async function BlogPost({ params }) {
  const post = await fetchPost(params.slug)

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'BlogPosting',
    headline: post.title,
    description: post.excerpt,
    image: post.heroImage,
    datePublished: post.publishedAt,
    dateModified: post.updatedAt,
    author: {
      '@type': 'Person',
      name: post.author.name,
      url: `https://acme.com/authors/${post.author.slug}`,
    },
    publisher: {
      '@type': 'Organization',
      name: 'Acme',
      logo: {
        '@type': 'ImageObject',
        url: 'https://acme.com/logo.png',
      },
    },
    mainEntityOfPage: {
      '@type': 'WebPage',
      '@id': `https://acme.com/blog/${params.slug}`,
    },
  }

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>{post.content}</article>
    </>
  )
}

Dynamic Sitemap

// app/sitemap.ts — Next.js generates /sitemap.xml automatically
import { MetadataRoute } from 'next'

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
  const posts = await fetchAllPosts()
  const products = await fetchAllProducts()

  const staticRoutes: MetadataRoute.Sitemap = [
    { url: 'https://acme.com', lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
    { url: 'https://acme.com/about', lastModified: new Date(), changeFrequency: 'monthly', priority: 0.8 },
    { url: 'https://acme.com/blog', lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
  ]

  const postRoutes: MetadataRoute.Sitemap = posts.map(post => ({
    url: `https://acme.com/blog/${post.slug}`,
    lastModified: new Date(post.updatedAt),
    changeFrequency: 'weekly',
    priority: 0.7,
  }))

  const productRoutes: MetadataRoute.Sitemap = products.map(product => ({
    url: `https://acme.com/products/${product.slug}`,
    lastModified: new Date(product.updatedAt),
    changeFrequency: 'weekly',
    priority: 0.6,
  }))

  return [...staticRoutes, ...postRoutes, ...productRoutes]
}

robots.txt

// app/robots.ts
import { MetadataRoute } from 'next'

export default function robots(): MetadataRoute.Robots {
  return {
    rules: [
      {
        userAgent: '*',
        allow: '/',
        disallow: ['/admin/', '/api/', '/private/'],
      },
      {
        userAgent: 'Googlebot',
        allow: '/',
        disallow: '/admin/',
      },
    ],
    sitemap: 'https://acme.com/sitemap.xml',
    host: 'https://acme.com',
  }
}

Canonical URLs

// Always set canonical to avoid duplicate content penalties
// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL('https://acme.com'),
  alternates: {
    canonical: '/',  // Relative — metadataBase makes it absolute
  },
}

// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }) {
  return {
    alternates: {
      canonical: `/blog/${params.slug}`,
    },
  }
}

// Generates: <link rel="canonical" href="https://acme.com/blog/my-post" />

Dynamic OG Images

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'

export const runtime = 'edge'
export const alt = 'Blog Post'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function Image({ params }: { params: { slug: string } }) {
  const post = await fetchPost(params.slug)

  return new ImageResponse(
    (
      <div
        style={{
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'flex-start',
          justifyContent: 'flex-end',
          width: '100%',
          height: '100%',
          background: 'linear-gradient(135deg, #6366f1, #22d3ee)',
          padding: '60px',
        }}
      >
        <div style={{ fontSize: 56, fontWeight: 700, color: 'white', lineHeight: 1.2 }}>
          {post.title}
        </div>
        <div style={{ fontSize: 28, color: 'rgba(255,255,255,0.8)', marginTop: 20 }}>
          {post.author.name} · {new Date(post.publishedAt).toLocaleDateString()}
        </div>
      </div>
    ),
    { ...size }
  )
}
// Next.js automatically links this as the og:image for /blog/[slug]