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.
Table of Contents
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]