Next.js 15 App Router handles SEO in TypeScript, co-located with your routes. This guide covers every layer — metadata, OG images, sitemaps, structured data — with real production patterns.
Two Ways to Define Metadata
Static export — when metadata doesn't depend on 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.', type: 'website' },
}
generateMetadata — when metadata depends on route params or DB data:
// 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' }
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
Configure once in the root layout — child pages only set their own title:
// app/layout.tsx
export const metadata: Metadata = {
title: {
template: '%s — Acme', // "Pricing — Acme"
default: 'Acme — Build faster',
},
}
// app/pricing/page.tsx
export const metadata: Metadata = {
title: 'Pricing', // renders as "Pricing — Acme"
}
Dynamic Open Graph Images
// 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, #0D1117)',
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: '56px', fontWeight: 900, color: 'white', lineHeight: 1.15 }}>
{post?.title ?? 'Blog Post'}
</div>
</div>
),
{ ...size }
)
}
The URL (/blog/my-post/opengraph-image) is automatically referenced in the page metadata. Next.js caches the output.
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'
return [
{ url: baseUrl, lastModified: new Date(), changeFrequency: 'daily', priority: 1.0 },
{ url: `${baseUrl}/blog`, lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
...posts.map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: 'weekly' as const,
priority: 0.8,
})),
]
}
Available at /sitemap.xml.
robots.txt
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin/'] }],
sitemap: 'https://example.com/sitemap.xml',
}
}
JSON-LD Structured Data
Add as a <script> tag in the page component — not through the metadata API:
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: Props) {
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 },
publisher: {
'@type': 'Organization',
name: 'Acme',
logo: { '@type': 'ImageObject', url: 'https://example.com/logo.png' },
},
datePublished: post.publishedAt.toISOString(),
dateModified: post.updatedAt.toISOString(),
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<article>{/* content */}</article>
</>
)
}
Canonical URLs
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
return {
alternates: {
canonical: `https://example.com/blog/${slug}`,
},
}
}
For paginated routes:
return {
alternates: {
canonical: page === '1'
? 'https://example.com/blog'
: `https://example.com/blog/page/${page}`,
},
}
Deduplicating Data Fetches
generateMetadata and the page component run independently. Use cache() to prevent double DB calls:
// lib/posts.ts
import { cache } from 'react'
export const getPost = cache(async (slug: string) => {
return db.post.findUnique({ where: { slug } })
})
Same call in generateMetadata and page.tsx = one query, two uses.
Common Pitfalls
- Missing descriptions — meta description is your ad copy in search results. Don't leave it empty.
- Generic OG images — per-page dynamic images improve social CTR noticeably.
- Duplicate titles — every page needs a unique title; the template handles the suffix, you handle the unique part.
- Unvalidated JSON-LD — invalid structured data is silently ignored. Test with Google's Rich Results Test.
- Not checking what social platforms see — use Twitter Card Validator and Facebook Sharing Debugger.
Quick Verification
# Check metadata in rendered HTML
curl -s https://your-site.com/blog/your-post | grep -E 'og:|twitter:|canonical'
# Validate sitemap
curl -s https://your-site.com/sitemap.xml | head -30
Full guide at stacknotice.com/blog/nextjs-seo-guide-2026
Top comments (0)