Sanity
Next.js App Router SEO Guide for AI and CMS Websites
Master Next.js App Router SEO with deep dives into generateMetadata, dynamic sitemaps, Open Graph, CMS-driven pages with Sanity, and AI-generated content strategies.

Next.js App Router SEO has matured into one of the most powerful frameworks for building search-engine-optimized web applications. Whether you're building a CMS-driven blog with Sanity or an AI-powered content platform, the App Router gives you fine-grained control over every SEO signal your pages emit. This guide walks through everything you need to know — from metadata APIs to sitemaps, Open Graph tags, and beyond.
SEO Fundamentals in the App Router
The Next.js App Router (introduced in Next.js 13 and stabilized in 14+) fundamentally changes how SEO metadata is managed. Unlike the Pages Router, where you'd use next/head to inject tags imperatively, the App Router uses a declarative metadata system built directly into the framework.
Every page.tsx or layout.tsx file can export a metadata object or a generateMetadata async function. Next.js collects these exports at build time (for static pages) or at request time (for dynamic pages) and renders the correct <head> tags server-side — which is exactly what search engine crawlers expect.
Key SEO concepts in the App Router:
- Server Components by default — pages are rendered on the server, so crawlers see fully hydrated HTML
- Metadata inheritance — metadata defined in a
layout.tsxis inherited by all child routes unless overridden - Streaming and Suspense — does not affect SEO because the initial HTML shell includes metadata
- Route Segments — each segment can contribute its own metadata, which Next.js merges intelligently
The shift to server-first rendering means that Next.js App Router SEO is no longer an afterthought — it's baked into the architecture.
generateMetadata API Deep Dive
The generateMetadata function is the cornerstone of dynamic Next.js App Router SEO. It's an async function exported from any page.tsx or layout.tsx that receives route params and returns a Metadata object.
Basic static metadata export:
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'My Blog Post',
description: 'A deep dive into Next.js App Router SEO.',
keywords: ['Next.js', 'SEO', 'App Router'],
}
Dynamic metadata with generateMetadata:
import type { Metadata, ResolvingMetadata } from 'next'
type Props = { params: { slug: string } }
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const post = await fetchPostBySlug(params.slug)
const previousImages = (await parent).openGraph?.images || []
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
images: [post.mainImage, ...previousImages],
},
}
}
The parent parameter gives you access to the resolved metadata from parent layouts, enabling you to extend rather than replace inherited values. This is especially useful for Open Graph images defined at the root layout level.
Title templates are another powerful feature:
// app/layout.tsx
export const metadata: Metadata = {
title: {
template: '%s | Acme Blog',
default: 'Acme Blog',
},
}
// app/blog/[slug]/page.tsx
export const metadata: Metadata = {
// Renders as: "Next.js App Router SEO Guide | Acme Blog"
title: 'Next.js App Router SEO Guide',
}
The template string uses %s as a placeholder for the child page's title. The default value is used when no child title is provided.
Key supported Metadata fields include: title, description, keywords, robots, alternates, openGraph, twitter, icons, verification, and other for arbitrary meta tags.
Dynamic vs Static Metadata
Choosing between static and dynamic metadata is a critical Next.js App Router SEO decision that affects both performance and correctness.
Static metadata is defined as a plain exported const. Next.js evaluates it at build time and embeds it directly into the static HTML. Use this for pages whose SEO data never changes:
// app/about/page.tsx
export const metadata: Metadata = {
title: 'About Us',
description: 'Learn about our team and mission.',
alternates: {
canonical: 'https://example.com/about',
},
}
Dynamic metadata is generated at request time via generateMetadata. Use this for any page whose title, description, or OG image depends on fetched data:
// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await getPost(params.slug)
if (!post) {
return { title: 'Post Not Found', robots: { index: false } }
}
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `https://example.com/blog/${post.slug}` },
openGraph: {
type: 'article',
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
},
}
}
Performance tip: Next.js automatically deduplicates fetch calls. If your generateMetadata and your page component both call getPost(params.slug), Next.js will only make one network request — as long as you use the native fetch API with the same URL and options.
For statically generated dynamic routes, combine generateMetadata with generateStaticParams:
export async function generateStaticParams() {
const posts = await getAllPostSlugs()
return posts.map((post) => ({ slug: post.slug }))
}
This tells Next.js which slugs to pre-render at build time, giving you static HTML with dynamic metadata — the best of both worlds for Next.js App Router SEO.
Sitemap and robots.txt Generation
A well-structured sitemap and a correct robots.txt are table-stakes for Next.js App Router SEO. The App Router makes both trivially easy with file-based conventions.
Generating a sitemap — create app/sitemap.ts and export a default async function:
import { MetadataRoute } from 'next'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await getAllPosts()
const postEntries = posts.map((post) => ({
url: `https://example.com/blog/${post.slug}`,
lastModified: new Date(post.updatedAt || post.publishedAt),
changeFrequency: 'weekly' as const,
priority: 0.8,
}))
return [
{ url: 'https://example.com', lastModified: new Date(), changeFrequency: 'daily', priority: 1 },
{ url: 'https://example.com/blog', lastModified: new Date(), changeFrequency: 'daily', priority: 0.9 },
...postEntries,
]
}
Next.js automatically serves this at /sitemap.xml with the correct application/xml content type.
Generating robots.txt — create app/robots.ts:
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{ userAgent: '*', allow: '/', disallow: ['/api/', '/admin/'] },
{ userAgent: 'GPTBot', disallow: '/' }, // Opt out of OpenAI training
],
sitemap: 'https://example.com/sitemap.xml',
host: 'https://example.com',
}
}
Open Graph and Twitter Cards
Open Graph and Twitter Card metadata control how your pages appear when shared on social media — a key part of any Next.js App Router SEO strategy.
Open Graph metadata:
export const metadata: Metadata = {
openGraph: {
title: 'Next.js App Router SEO Guide',
description: 'Everything you need to know about SEO in the App Router.',
url: 'https://example.com/blog/nextjs-app-router-seo',
siteName: 'Acme Blog',
images: [{
url: 'https://example.com/og/nextjs-seo.png',
width: 1200,
height: 630,
alt: 'Next.js App Router SEO Guide cover image',
}],
locale: 'en_US',
type: 'article',
},
}
Twitter Card metadata:
export const metadata: Metadata = {
twitter: {
card: 'summary_large_image',
title: 'Next.js App Router SEO Guide',
description: 'Everything you need to know about SEO in the App Router.',
creator: '@yourtwitterhandle',
images: ['https://example.com/og/nextjs-seo.png'],
},
}
Dynamically generated OG images with next/og — create app/blog/[slug]/opengraph-image.tsx:
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
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: '#0f172a', width: '100%', height: '100%',
display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '80px' }}>
<p style={{ color: '#60a5fa', fontSize: 28 }}>Acme Blog</p>
<h1 style={{ color: 'white', fontSize: 64, lineHeight: 1.2 }}>{post.title}</h1>
</div>
),
{ ...size }
)
}
Next.js automatically wires up the og:image and twitter:image meta tags to point to this route. No manual configuration needed.
SEO for CMS-Driven Pages (Sanity)
When your content lives in a headless CMS like Sanity, Next.js App Router SEO requires a bridge between your CMS data model and the Metadata API.
Setting up the Sanity client:
// lib/sanity.ts
import { createClient } from 'next-sanity'
export const client = createClient({
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
apiVersion: '2024-01-01',
useCdn: process.env.NODE_ENV === 'production',
})
GROQ query for SEO fields:
export const postSeoQuery = groq`
*[_type == "post" && slug.current == $slug][0] {
title,
"slug": slug.current,
excerpt,
publishedAt,
updatedAt,
"mainImage": mainImage {
"url": asset->url,
alt,
"width": asset->metadata.dimensions.width,
"height": asset->metadata.dimensions.height,
},
"author": author->name,
seo { title, description, canonicalUrl,
"openGraphImage": openGraphImage { "url": asset->url, alt }
}
}
`
Wiring Sanity data into generateMetadata:
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const post = await client.fetch(postSeoQuery, { slug: params.slug })
if (!post) return { title: 'Not Found' }
const title = post.seo?.title || post.title
const description = post.seo?.description || post.excerpt
const canonical = post.seo?.canonicalUrl || `https://example.com/blog/${post.slug}`
const ogImage = post.seo?.openGraphImage?.url || post.mainImage?.url
return {
title,
description,
alternates: { canonical },
openGraph: {
title, description, type: 'article',
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: post.author ? [post.author] : undefined,
images: ogImage ? [{ url: ogImage, alt: post.mainImage?.alt }] : undefined,
},
twitter: { card: 'summary_large_image', title, description,
images: ogImage ? [ogImage] : undefined },
}
}
Structured data (JSON-LD) for Sanity posts:
// components/ArticleJsonLd.tsx
export function ArticleJsonLd({ post }: { post: Post }) {
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
datePublished: post.publishedAt,
dateModified: post.updatedAt || post.publishedAt,
author: { '@type': 'Person', name: post.author },
image: post.mainImage?.url,
}
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
)
}
Inject this component directly in your page.tsx — it renders server-side and is fully crawlable.
Integrating AI-Generated Content for SEO
AI-generated content is increasingly common, but it introduces unique Next.js App Router SEO challenges. The core principle: AI-generated content should be treated as a draft that requires SEO optimization before publishing. Raw LLM output is rarely keyword-optimized, properly structured, or unique enough to rank well on its own.
Generating SEO metadata with AI:
// lib/ai-seo.ts
import OpenAI from 'openai'
const openai = new OpenAI()
export async function generateSeoMetadata(content: string, primaryKeyword: string) {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: 'You are an SEO expert. Return JSON only.' },
{ role: 'user', content:
`Generate SEO metadata. Primary keyword: "${primaryKeyword}".
Content: ${content.slice(0, 3000)}
Return JSON: { "title": string (max 60 chars), "description": string (max 160 chars) }` },
],
response_format: { type: 'json_object' },
})
return JSON.parse(response.choices[0].message.content!)
}
Run this during your content ingestion pipeline and store the result in your Sanity document's seo field. This way, the metadata is static at request time — no AI calls in generateMetadata.
Semantic richness matters: AI content tends to be generic. For strong Next.js App Router SEO, enrich AI drafts with original data and statistics, author expertise signals (bylines, bios, credentials), internal links to related content, structured data markup, and unique visuals.
Common Mistakes
Even experienced developers make these Next.js App Router SEO mistakes:
- Using next/head in App Router pages. The
next/headcomponent is for the Pages Router only. In the App Router, it's silently ignored. Always use themetadataexport orgenerateMetadata. - Forgetting canonical URLs. Without explicit canonical URLs, search engines may index multiple versions of the same page. Always set
alternates.canonical. - Not handling notFound() in generateMetadata. If a page doesn't exist and you return empty metadata, search engines may index a blank 404 page. Always call
notFound()or returnrobots: { index: false }for missing content. - Duplicate title tags from nested layouts. If both a layout and a page export
title, the page's title wins — but without title templates you may end up with inconsistent titles. Usetitle.templatein root layouts. - Blocking CSS/JS in robots.txt. Googlebot needs to render your pages to evaluate them. Never disallow
/_next/static/or CSS/JS files. - Missing alt text on images. Every
<Image>component should have a descriptivealtattribute. This affects both accessibility and image search SEO. - Ignoring generateStaticParams for dynamic routes. Without
generateStaticParams, dynamic routes are rendered on-demand (SSR). For blog posts, this means slower TTFB and no static HTML for crawlers to cache. - Hardcoding absolute URLs. Always derive your base URL from an environment variable:
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com'.
Best Practices
Structure your metadata hierarchy deliberately. Define global defaults in app/layout.tsx, section-level overrides in segment layouts (e.g., app/blog/layout.tsx), and page-specific metadata in individual page.tsx files.
Use title templates consistently. A title.template in your root layout ensures every page has a branded title without manual repetition.
Implement JSON-LD structured data. For articles, products, FAQs, and breadcrumbs, structured data significantly improves rich result eligibility in Google Search.
Prioritize Core Web Vitals. SEO is not just metadata — Google's ranking algorithm weighs LCP, CLS, and INP heavily. Use next/image for automatic image optimization, and avoid layout shifts.
Use revalidate strategically. For CMS-driven pages, set export const revalidate = 3600 or use on-demand revalidation via webhooks to keep content fresh without sacrificing static performance.
Monitor with Google Search Console. Connect your sitemap, monitor coverage errors, and track Core Web Vitals data from real users.
Test your metadata. Use the Open Graph Debugger, Twitter Card Validator, and Google's Rich Results Test before publishing.
Handle i18n with hreflang. If your site serves multiple languages, use alternates.languages to declare hreflang relationships and avoid duplicate content penalties across locales.
FAQ
Can I use generateMetadata in a layout file?
Yes. generateMetadata works in both layout.tsx and page.tsx. Metadata from layouts is inherited by all child routes. This is useful for setting section-wide defaults, like an og:site_name or a title template for a blog section.
Does Next.js App Router SEO work with React Server Components?
Absolutely. The App Router's server-first architecture is a major SEO advantage. All metadata is resolved server-side before the HTML is sent to the client, so crawlers always see complete, accurate <head> tags — no JavaScript execution required.
How do I handle SEO for paginated routes (e.g., /blog?page=2)?
Use rel="canonical" pointing to the first page for paginated content. The most SEO-friendly approach is path-based pagination (/blog/page/2) with unique canonical URLs for each page, rather than query-string pagination.
Should I use the keywords metadata field?
Google has ignored the keywords meta tag since 2009. Focus your keyword strategy on title, description, headings, and body content instead. You can include it for other search engines, but it has no meaningful impact on Google rankings.
How do I prevent staging or preview environments from being indexed?
Add a robots metadata export to your root layout that conditionally blocks indexing based on an environment variable:
export const metadata: Metadata = {
robots: {
index: process.env.NEXT_PUBLIC_SITE_ENV === 'production',
follow: process.env.NEXT_PUBLIC_SITE_ENV === 'production',
},
}
Conclusion
Next.js App Router SEO represents a significant leap forward from the Pages Router era. The declarative metadata system, built-in sitemap and robots.txt generation, dynamic OG image support, and seamless integration with headless CMS platforms like Sanity give developers everything they need to build world-class, search-optimized web applications.
Key takeaways from this guide:
- Use
generateMetadatafor dynamic pages and themetadataexport for static ones - Always set canonical URLs, especially on CMS-driven sites
- Generate your sitemap programmatically from your CMS data
- Use
next/ogfor dynamic Open Graph images — it's fast, edge-compatible, and zero-config - Treat AI-generated content as a draft; enrich it with original insights and proper metadata before publishing
- Combine
generateStaticParamswithgenerateMetadatafor the best possible performance and crawlability
With these patterns in place, your Next.js App Router application will be well-positioned to rank, be discovered, and deliver a great experience to both users and search engines alike.


