Next.js App Router Metadata API: The Complete SEO Setup
App Router's metadata API replaces next/head with a static-or-dynamic export. Here's the production pattern for SEO-correct metadata.
The App Router's Metadata API replaces next/head entirely. Instead of a component you render, you export a metadata object or a generateMetadata function from any layout.tsx or page.tsx. Next.js collects these exports down the layout tree, merges them, and renders the final <head> on the server.
This is a better model than next/head for several reasons: it's type-safe, it composes through the layout tree, and it makes canonical URLs and Open Graph tags first-class citizens rather than raw <meta> strings.
Here's the production pattern.
The Static Metadata Export
For pages where the metadata doesn't depend on dynamic data — your homepage, about page, pricing page — export a metadata object directly:
// app/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'RankCrab — On-Page SEO for Developers',
description: 'Audit, optimize, and track rankings for your Next.js site.',
openGraph: {
title: 'RankCrab — On-Page SEO for Developers',
description: 'Audit, optimize, and track rankings for your Next.js site.',
url: 'https://rankcrab.com',
siteName: 'RankCrab',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'RankCrab — On-Page SEO for Developers',
description: 'Audit, optimize, and track rankings for your Next.js site.',
},
}
This is all you need for a static page. Next.js will render the correct <title>, <meta name="description">, and Open Graph tags from this object alone.
metadataBase: Set It First
Before anything else, set metadataBase in your root layout. Without it, every relative URL in your metadata — og:url, canonical href, OG image src — resolves to http://localhost:3000 in production.
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
metadataBase: new URL('https://yourdomain.com'),
}
With metadataBase set, you can use relative paths everywhere else:
openGraph: {
url: '/blog/my-post', // → https://yourdomain.com/blog/my-post
images: ['/og/my-post.png'], // → https://yourdomain.com/og/my-post.png
}
title.template
Set a title template in your root layout so every page automatically gets your site name appended:
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL('https://yourdomain.com'),
title: {
default: 'Your Site Name',
template: '%s | Your Site Name',
},
}
Now any page that exports just a string title gets the template applied automatically:
// app/blog/page.tsx
export const metadata: Metadata = {
title: 'Blog', // renders as "Blog | Your Site Name"
}
Use title.absolute when you need to override the template entirely (e.g., for your homepage):
// app/page.tsx
export const metadata: Metadata = {
title: {
absolute: 'Your Site Name — The Tagline Goes Here',
},
}
generateMetadata for Dynamic Pages
Blog posts, product pages, user profiles — any page where the metadata comes from a database or CMS needs generateMetadata. This is an async function that receives the route params and can fetch data:
// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'
type Props = {
params: Promise<{ slug: string }>
}
export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata
): Promise<Metadata> {
const { slug } = await params
const post = await getPost(slug)
// Access parent metadata (e.g., to extend openGraph images)
const previousImages = (await parent).openGraph?.images || []
return {
title: post.title,
description: post.excerpt,
alternates: {
canonical: `/blog/${slug}`,
},
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
publishedTime: post.publishedAt,
authors: [post.author],
images: [
{
url: `/blog/${slug}/opengraph-image`,
width: 1200,
height: 630,
},
...previousImages,
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
},
}
}
Next.js deduplicates the getPost fetch automatically when you use it in both generateMetadata and the page component — the data is only fetched once.
A Complete Typed Example
Here's the full production pattern for a blog post page, combining everything above:
// app/blog/[slug]/page.tsx
import type { Metadata, ResolvingMetadata } from 'next'
import { notFound } from 'next/navigation'
type Props = {
params: Promise<{ slug: string }>
}
async function getPost(slug: string) {
const res = await fetch(`${process.env.API_URL}/posts/${slug}`, {
next: { revalidate: 3600 },
})
if (!res.ok) return null
return res.json() as Promise<{
title: string
excerpt: string
content: string
publishedAt: string
updatedAt: string
author: string
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' }
}
return {
title: post.title,
description: post.excerpt,
alternates: {
canonical: `/blog/${post.slug}`,
},
openGraph: {
title: post.title,
description: post.excerpt,
url: `/blog/${post.slug}`,
type: 'article',
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author],
images: [
{
url: `/blog/${post.slug}/opengraph-image`,
width: 1200,
height: 630,
alt: post.title,
},
],
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.excerpt,
},
}
}
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
if (!post) notFound()
return <article>{/* render post.content */}</article>
}
The robots Metadata Field
Control indexing on a per-page basis with metadata.robots:
// Prevent indexing — useful for admin pages and intentionally excluded preview URLs
export const metadata: Metadata = {
robots: {
index: false,
follow: false,
},
}
// Explicit allow (this is the default, but useful when overriding a parent layout)
export const metadata: Metadata = {
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
}
Inheritance Down the Layout Tree
Metadata is merged from the outermost layout inward. Inner layouts and pages override outer ones. Rules:
- Simple fields (
titlestring,description) are overridden entirely. openGraph.imagesarrays are overridden entirely unless you explicitly spread parent images (as shown in thegenerateMetadataexample above).metadataBaseis only set once in the root layout — inner layouts don't need to set it.robotsset on a layout applies to all pages inside that layout segment unless overridden.
This means your root app/layout.tsx is the right place for metadataBase, title.template, and site-wide defaults. Individual pages override what they need.
Streaming metadata: source and DOM can differ
Current Next.js generateMetadata documentation describes metadata streaming and special handling for HTML-limited bots. This behavior was introduced after Next.js 15.1.4; do not assume every project version uses the same defaults. The following is documentation-based guidance, not a measured streaming-metadata test of every version.
| Capture | What to record | Why |
|---|---|---|
| Original document response | Title, description, canonical, robots tags and location | Shows what this request initially/fully received |
| Browser DOM after completion | Same elements and duplicates | Shows changes after rendering |
| Documented HTML-limited bot request | Response tags and timing | Tests your framework's user-agent handling |
| Google URL Inspection | Available rendered output and crawl date | Provides separate evidence from Google's test |
A tag found in the body should trigger diagnosis, not an automatic claim that Google has lost your title. Missing tags, conflicting canonicals and incorrect directives still deserve investigation. Use the raw-versus-rendered HTML diff with two saved snapshots. It does not fetch your URL, execute JavaScript or simulate Googlebot.
Only change htmlLimitedBots after checking the installed version's documentation and reproducing the actual problem. A configuration that forces blocking metadata can alter response timing; do not disable streaming globally solely to silence one parser warning.
Verify the intended values
Use the meta tag previewer for an approximate preview of supplied title/description text. It cannot guarantee Google's displayed snippet or verify social-crawler access. Inspect the published tags and test the relevant platform separately.
- The title and description accurately describe this route.
- The canonical points to the intended URL.
- Public pages do not inherit an accidental noindex.
- Social images resolve publicly at their absolute URLs.
- Known and missing routes both behave intentionally.
For a measured response-status example, see notFound returning 200. Continue with canonical URLs or dynamic OG images for those implementation details. RankCrab's paid workspace is prelaunch; launch notifications are optional.