How to make your MDX-powered Next.js site rank well — metadata, Open Graph images, sitemaps, JSON-LD, and canonical URLs.
If you're building a content site with MDX and Next.js, you need SEO to work well. The good news: Next.js gives you great tools for this — the Metadata API, dynamic OG image generation, auto-sitemaps, and structured data. Here's everything you need.
Project Structure for SEO
my-site/
├── app/
│ ├── layout.tsx ← Root metadata
│ ├── page.tsx
│ ├── robots.ts ← robots.txt generation
│ ├── sitemap.ts ← Sitemap generation
│ ├── blog/
│ │ ├── [slug]/
│ │ │ ├── page.tsx ← Dynamic metadata
│ │ │ └── opengraph-image.tsx ← Dynamic OG images
│ │ └── page.tsx
│ └── api/
│ └── og/
│ └── route.tsx ← OG image API route
├── content/
│ └── posts/
│ ├── intro-to-mdx.mdx
│ └── advanced-patterns.mdx
└── lib/
├── mdx.ts
└── structured-data.ts
Dynamic Open Graph Images with @vercel/og
Want nice preview cards when people share your posts on Twitter or Slack? Generate them on the fly.
Using the opengraph-image Convention
Drop an opengraph-image.tsx file in your route folder and Next.js picks it up automatically:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPostBySlug } from '@/lib/mdx'
export const runtime = 'edge'
export const alt = 'Blog post cover'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
interface Props {
params: { slug: string }
}
export default async function OGImage({ params }: Props) {
const post = getPostBySlug(params.slug)
// Load custom font
const interBold = await fetch(
new URL('../../fonts/Inter-Bold.ttf', import.meta.url)
).then((res) => res.arrayBuffer())
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '60px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
fontFamily: 'Inter',
}}
>
{/* Category Tag */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span
style={{
background: 'rgba(255,255,255,0.2)',
padding: '6px 16px',
borderRadius: '20px',
fontSize: '16px',
}}
>
{post?.frontmatter.category || 'Blog'}
</span>
</div>
{/* Title */}
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '16px',
}}
>
<h1
style={{
fontSize: '56px',
fontWeight: 'bold',
lineHeight: 1.2,
margin: 0,
maxWidth: '80%',
}}
>
{post?.frontmatter.title || 'Blog Post'}
</h1>
<p
style={{
fontSize: '24px',
opacity: 0.8,
margin: 0,
maxWidth: '70%',
}}
>
{post?.frontmatter.description || ''}
</p>
</div>
{/* Footer */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontSize: '20px' }}>
{post?.frontmatter.author || 'Author'}
</span>
<span style={{ fontSize: '18px', opacity: 0.7 }}>
yourdomain.com
</span>
</div>
</div>
),
{
...size,
fonts: [
{
name: 'Inter',
data: interBold,
style: 'normal',
weight: 700,
},
],
}
)
}
API Route for OG Images
If you want a reusable OG endpoint that any page can hit with query params:
// app/api/og/route.tsx
import { ImageResponse } from 'next/og'
import { NextRequest } from 'next/server'
export const runtime = 'edge'
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const title = searchParams.get('title') || 'My Blog'
const description = searchParams.get('description') || ''
const date = searchParams.get('date') || ''
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
background: '#1a1a2e',
color: 'white',
padding: '80px',
textAlign: 'center',
}}
>
<h1 style={{ fontSize: '48px', marginBottom: '20px' }}>{title}</h1>
{description && (
<p style={{ fontSize: '24px', opacity: 0.7 }}>{description}</p>
)}
{date && (
<p style={{ fontSize: '18px', opacity: 0.5, marginTop: '20px' }}>
{date}
</p>
)}
</div>
),
{ width: 1200, height: 630 }
)
}
Sitemap Generation from MDX Files
Your sitemap gets built automatically from your MDX content. Google finds all your posts without you manually maintaining anything.
Automatic Sitemap
// app/sitemap.ts
import { MetadataRoute } from 'next'
import { getAllPosts } from '@/lib/mdx'
const SITE_URL = 'https://yourdomain.com'
export default function sitemap(): MetadataRoute.Sitemap {
const posts = getAllPosts()
const blogEntries: MetadataRoute.Sitemap = posts.map((post) => ({
url: `${SITE_URL}/blog/${post.slug}`,
lastModified: new Date(post.frontmatter.date),
changeFrequency: 'weekly',
priority: 0.8,
}))
// Static pages
const staticPages: MetadataRoute.Sitemap = [
{
url: SITE_URL,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 1.0,
},
{
url: `${SITE_URL}/blog`,
lastModified: new Date(),
changeFrequency: 'daily',
priority: 0.9,
},
{
url: `${SITE_URL}/about`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.5,
},
]
return [...staticPages, ...blogEntries]
}
Large Site: Multiple Sitemaps
Got thousands of posts? Split your sitemap into chunks:
// app/sitemap/[id]/route.ts
import { getAllPosts } from '@/lib/mdx'
const SITE_URL = 'https://yourdomain.com'
const POSTS_PER_SITEMAP = 1000
export async function generateSitemaps() {
const posts = getAllPosts()
const numSitemaps = Math.ceil(posts.length / POSTS_PER_SITEMAP)
return Array.from({ length: numSitemaps }, (_, i) => ({ id: i }))
}
export default function sitemap({ id }: { id: number }) {
const posts = getAllPosts()
const start = id * POSTS_PER_SITEMAP
const end = start + POSTS_PER_SITEMAP
return posts.slice(start, end).map((post) => ({
url: `${SITE_URL}/blog/${post.slug}`,
lastModified: new Date(post.frontmatter.date),
}))
}
JSON-LD Structured Data
Structured data gives search engines extra context about your content. This is how you get those nice rich snippets in search results.
Structured Data Utility
// lib/structured-data.ts
import { Post } from './mdx'
const SITE_URL = 'https://yourdomain.com'
export function generateArticleJsonLd(post: Post) {
return {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.frontmatter.title,
description: post.frontmatter.description,
image: post.frontmatter.image
? `${SITE_URL}${post.frontmatter.image}`
: `${SITE_URL}/api/og?title=${encodeURIComponent(post.frontmatter.title)}`,
datePublished: post.frontmatter.date,
dateModified: post.frontmatter.date,
author: {
'@type': 'Person',
name: post.frontmatter.author,
url: `${SITE_URL}/about`,
},
publisher: {
'@type': 'Organization',
name: 'My MDX Blog',
logo: {
'@type': 'ImageObject',
url: `${SITE_URL}/logo.png`,
},
},
mainEntityOfPage: {
'@type': 'WebPage',
'@id': `${SITE_URL}/blog/${post.slug}`,
},
keywords: post.frontmatter.tags.join(', '),
articleSection: post.frontmatter.category,
wordCount: post.content.split(/\s+/).length,
}
}
export function generateBreadcrumbJsonLd(
items: { name: string; url: string }[]
) {
return {
'@context': 'https://schema.org',
'@type': 'BreadcrumbList',
itemListElement: items.map((item, index) => ({
'@type': 'ListItem',
position: index + 1,
name: item.name,
item: item.url,
})),
}
}
export function generateWebsiteJsonLd() {
return {
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'My MDX Blog',
url: SITE_URL,
potentialAction: {
'@type': 'SearchAction',
target: {
'@type': 'EntryPoint',
urlTemplate: `${SITE_URL}/search?q={search_term_string}`,
},
'query-input': 'required name=search_term_string',
},
}
}
Adding JSON-LD to Blog Posts
Here's how you inject the structured data into your actual page:
// app/blog/[slug]/page.tsx
import { generateArticleJsonLd, generateBreadcrumbJsonLd } from '@/lib/structured-data'
import { MDXRemote } from 'next-mdx-remote/rsc'
const SITE_URL = 'https://yourdomain.com'
export default function BlogPost({ params }: Props) {
const post = getPostBySlug(params.slug)
if (!post) notFound()
const articleJsonLd = generateArticleJsonLd(post)
const breadcrumbJsonLd = generateBreadcrumbJsonLd([
{ name: 'Home', url: SITE_URL },
{ name: 'Blog', url: `${SITE_URL}/blog` },
{ name: post.frontmatter.title, url: `${SITE_URL}/blog/${post.slug}` },
])
return (
<>
{/* Structured Data */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(articleJsonLd),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(breadcrumbJsonLd),
}}
/>
<article>
<h1>{post.frontmatter.title}</h1>
<MDXRemote source={post.content} />
</article>
</>
)
}
robots.txt Generation
Tell crawlers what they can and can't index:
// app/robots.ts
import { MetadataRoute } from 'next'
const SITE_URL = 'https://yourdomain.com'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/', '/_next/'],
},
{
userAgent: 'Googlebot',
allow: '/',
disallow: ['/api/'],
},
],
sitemap: `${SITE_URL}/sitemap.xml`,
host: SITE_URL,
}
}
Canonical URLs
Every page needs a canonical URL. This prevents Google from seeing your content as duplicated across different URL patterns.
Automatic Canonical in Layout
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = getPostBySlug(params.slug)
return {
// ... other metadata
alternates: {
canonical: `${SITE_URL}/blog/${params.slug}`,
languages: {
'en-US': `${SITE_URL}/en/blog/${params.slug}`,
'es-ES': `${SITE_URL}/es/blog/${params.slug}`,
},
},
}
}
Canonical URL Helper
// lib/seo.ts
export function getCanonicalUrl(path: string): string {
const SITE_URL = 'https://yourdomain.com'
// Remove trailing slashes and normalize
const normalizedPath = path.replace(/\/+$/, '').replace(/^\/+/, '/')
return `${SITE_URL}${normalizedPath}`
}
Pages Router SEO (Legacy)
Still on the Pages Router? Use next/head instead:
// pages/blog/[slug].tsx
import Head from 'next/head'
import { generateArticleJsonLd } from '@/lib/structured-data'
export default function BlogPost({ frontmatter, source }: Props) {
const SITE_URL = 'https://yourdomain.com'
const jsonLd = generateArticleJsonLd(frontmatter)
return (
<>
<Head>
<title>{frontmatter.title} | My Blog</title>
<meta name="description" content={frontmatter.description} />
<meta name="keywords" content={frontmatter.tags.join(', ')} />
<link rel="canonical" href={`${SITE_URL}/blog/${frontmatter.slug}`} />
{/* Open Graph */}
<meta property="og:type" content="article" />
<meta property="og:title" content={frontmatter.title} />
<meta property="og:description" content={frontmatter.description} />
<meta property="og:url" content={`${SITE_URL}/blog/${frontmatter.slug}`} />
<meta property="og:image" content={`${SITE_URL}/api/og?title=${encodeURIComponent(frontmatter.title)}`} />
<meta property="article:published_time" content={frontmatter.date} />
<meta property="article:author" content={frontmatter.author} />
{/* Twitter */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={frontmatter.title} />
<meta name="twitter:description" content={frontmatter.description} />
{/* JSON-LD */}
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</Head>
<article>
<MDXRemote {...source} components={components} />
</article>
</>
)
}
SEO Audit Checklist for MDX Sites
Here's a quick script you can run to catch SEO problems before they hurt your rankings:
// scripts/seo-audit.ts
import { getAllPosts } from '@/lib/mdx'
function auditSEO() {
const posts = getAllPosts()
const issues: string[] = []
posts.forEach((post) => {
const { title, description, tags, image } = post.frontmatter
// Title length check (50-60 chars optimal)
if (title.length > 60) {
issues.push(`[${post.slug}] Title too long: ${title.length} chars`)
}
if (title.length < 30) {
issues.push(`[${post.slug}] Title too short: ${title.length} chars`)
}
// Description length check (150-160 chars optimal)
if (!description) {
issues.push(`[${post.slug}] Missing description`)
} else if (description.length > 160) {
issues.push(`[${post.slug}] Description too long: ${description.length} chars`)
} else if (description.length < 120) {
issues.push(`[${post.slug}] Description too short: ${description.length} chars`)
}
// Tags check
if (!tags || tags.length === 0) {
issues.push(`[${post.slug}] Missing tags`)
}
// Image check
if (!image) {
issues.push(`[${post.slug}] No featured image (will use generated OG)`)
}
})
console.log(`\nSEO Audit Results: ${issues.length} issues found\n`)
issues.forEach((issue) => console.log(` ⚠️ ${issue}`))
}
auditSEO()
Summary
Here's what handles what, depending on your router:
| Feature | App Router | Pages Router |
|---|
| Metadata | generateMetadata() | next/head |
| OG Images | opengraph-image.tsx | API routes |
| Sitemap | app/sitemap.ts | next-sitemap package |
| Robots | app/robots.ts | public/robots.txt |
| JSON-LD | <script> in page | <Head> component |
| Canonical | alternates.canonical | <link rel="canonical"> |
The big things to remember:
- Pull metadata from frontmatter —
generateMetadata makes this automatic - Generate OG images on the fly —
@vercel/og gives you great social cards without manual design - Auto-generate sitemaps — read your MDX directory and you're done
- Add JSON-LD — this is how you get rich search results
- Set canonicals everywhere — duplicate content kills your rankings
- Audit regularly — catch issues early with a simple script