Next.js 15 App Router ships with a metadata API that handles everything from Open Graph tags to sitemaps to structured data — all in TypeScript, all co-located with your routes. This guide covers every layer of it with real production patterns.
The Two Ways to Define Metadata
Static metadata export — for pages where the content doesn't depend on route params or fetched data:
// app/about/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'About Us',
description: 'Learn about our team and mission.',
openGraph: {
title: 'About Us',
description: 'Learn about our team and mission.',
type: 'website',
},
}
export default function AboutPage() {
return <main>{/* content */}</main>
}generateMetadata function — for pages that need data from params or the database:
// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'
import { getPost } from '@/lib/posts'
interface Props {
params: Promise<{ slug: string }>
}
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
if (!post) {
return { title: 'Post not found' }
}
// Inherit images from parent metadata (layout)
const parentImages = (await parent).openGraph?.images ?? []
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt.toISOString(),
authors: [post.author.name],
images: [
{ url: post.coverImage, width: 1200, height: 630, alt: post.title },
...parentImages,
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
images: [post.coverImage],
},
}
}Title Templates
Instead of manually appending the site name to every page title, configure a template in the root layout:
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
template: '%s — Acme',
default: 'Acme — Build faster', // used when no title is set
},
description: 'Acme helps teams ship faster.',
}Child pages only need their own title — the template handles the rest:
// app/pricing/page.tsx
export const metadata: Metadata = {
title: 'Pricing', // renders as: "Pricing — Acme"
}The default applies to any page that doesn't define its own title. The template applies to all pages that do.
Dynamic Open Graph Images
Option 1: Static file — drop an opengraph-image.png (1200×630) in any route folder. Next.js picks it up automatically with no code.
Option 2: Generated at request time using ImageResponse:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/lib/posts'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function OgImage({
params,
}: {
params: { slug: string }
}) {
const post = await getPost(params.slug)
return new ImageResponse(
(
<div
style={{
background: 'linear-gradient(135deg, #080B14 0%, #0D1117 100%)',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
padding: '64px',
fontFamily: 'sans-serif',
}}
>
<div style={{ fontSize: '18px', color: '#38BDF8', letterSpacing: '3px', marginBottom: '20px' }}>
YOUR SITE
</div>
<div
style={{
fontSize: post && post.title.length > 60 ? '44px' : '56px',
fontWeight: 900,
color: 'white',
lineHeight: 1.15,
maxWidth: '1000px',
}}
>
{post?.title ?? 'Blog Post'}
</div>
</div>
),
{ ...size }
)
}The generated image URL (/blog/my-post/opengraph-image) is automatically referenced in the page's metadata. Next.js caches the output.
Load custom fonts with fetch() inside the function:
const fontData = await fetch(new URL('./fonts/Inter-Bold.ttf', import.meta.url)).then(r => r.arrayBuffer())
return new ImageResponse((...), { ...size, fonts: [{ name: 'Inter', data: fontData, weight: 700 }] })sitemap.xml
// app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/posts'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
const baseUrl = 'https://example.com'
const postEntries = posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
}))
return [
{ url: baseUrl, lastModified: new Date(), changeFrequency: 'daily', priority: 1.0 },
{ url: `${baseUrl}/blog`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
...postEntries,
]
}Available at /sitemap.xml. For large sites with thousands of URLs, use generateSitemaps to split into multiple files.
robots.txt
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', '/dashboard/'],
},
],
sitemap: 'https://example.com/sitemap.xml',
}
}Available at /robots.txt.
JSON-LD Structured Data
Structured data gives search engines explicit signals about your content. It can trigger rich results — article authorship, breadcrumbs, FAQs — in search.
Add it as a <script> tag in the page component, not through the metadata API:
// app/blog/[slug]/page.tsx
import { getPost } from '@/lib/posts'
export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPost(slug)
if (!post) return null
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
image: post.coverImage,
author: {
'@type': 'Person',
name: post.author.name,
url: `https://example.com/authors/${post.author.slug}`,
},
publisher: {
'@type': 'Organization',
name: 'Acme',
logo: { '@type': 'ImageObject', url: 'https://example.com/logo.png' },
},
datePublished: post.publishedAt.toISOString(),
dateModified: post.updatedAt.toISOString(),
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `https://example.com/blog/${post.slug}`,
},
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{/* post content */}</article>
</>
)
}Breadcrumb schema (add alongside the article schema):
const breadcrumbJsonLd = {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: [
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://example.com' },
{ '@type': 'ListItem', position: 2, name: 'Blog', item: 'https://example.com/blog' },
{ '@type': 'ListItem', position: 3, name: post.title, item: `https://example.com/blog/${post.slug}` },
],
}Validate with Google's Rich Results Test after publishing.
Canonical URLs
The canonical tag tells search engines which URL is the authoritative version — critical for paginated lists, filtered views, or content cross-posted elsewhere (like dev.to with a canonical back to your site).
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
return {
alternates: {
canonical: `https://example.com/blog/${slug}`,
},
}
}For paginated routes, the first page should canonicalize to the non-paginated URL:
// app/blog/page/[page]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { page } = await params
return {
alternates: {
canonical: page === '1'
? 'https://example.com/blog'
: `https://example.com/blog/page/${page}`,
},
}
}hreflang for Multilingual Sites
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { lang, slug } = await params
return {
alternates: {
canonical: `https://example.com/${lang}/blog/${slug}`,
languages: {
'en': `https://example.com/en/blog/${slug}`,
'es': `https://example.com/es/blog/${slug}`,
'x-default': `https://example.com/en/blog/${slug}`,
},
},
}
}Metadata in Layouts
Metadata defined in a layout applies to all pages underneath it and can be overridden by child pages:
// app/blog/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
openGraph: {
type: 'article',
siteName: 'Acme Blog',
},
}
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return <section>{children}</section>
}All pages under /blog/ inherit openGraph.type: 'article' without having to set it explicitly.
Deduplicating Data Fetches
generateMetadata and the page component run independently — if both call getPost(slug), that's two database queries. Fix it with React's cache():
// lib/posts.ts
import { cache } from 'react'
export const getPost = cache(async (slug: string) => {
return db.post.findUnique({ where: { slug } })
})Now getPost('my-post') called in generateMetadata and again in page.tsx within the same request is deduplicated — one query, two uses.
This is the server components pattern applied to data fetching: fetch once, use everywhere.
Common Pitfalls
Missing descriptions on key pages — The meta description doesn't directly affect rankings, but it's your ad copy in search results. An empty one means Google picks random text from the page. Write them.
Generic OG images — A root-level opengraph-image.png is better than nothing. A dynamic per-post image with the actual title and design is noticeably better for CTR on social shares.
Duplicate or empty titles — Every page needs a unique, descriptive title. The template helps enforce the suffix, but the unique part still needs to come from your data.
Unvalidated structured data — Invalid JSON-LD is silently ignored by Google. Always test with the Rich Results Test before assuming it works.
Not testing what social platforms see — Use Twitter's Card Validator and Facebook's Sharing Debugger. They show you exactly what will appear on social posts, including cached data from previous versions.
Quick Verification
# Check metadata in rendered HTML
curl -s https://your-site.com/blog/your-post \
| grep -E 'og:|twitter:|canonical|description'
# Validate sitemap structure
curl -s https://your-site.com/sitemap.xml | head -30
# Check robots.txt
curl -s https://your-site.com/robots.txtAfter publishing, use Google Search Console's URL Inspection tool to see what Googlebot sees and whether structured data was parsed correctly.
The Next.js App Router guide covers the routing architecture that metadata builds on. For the performance side of Core Web Vitals — which also affects ranking — see the Next.js performance optimization guide.