SEO for Next.js

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.

Updated Sep 28, 20268 min readBy RankCrab Team

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 (title string, description) are overridden entirely.
  • openGraph.images arrays are overridden entirely unless you explicitly spread parent images (as shown in the generateMetadata example above).
  • metadataBase is only set once in the root layout — inner layouts don't need to set it.
  • robots set 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.

CaptureWhat to recordWhy
Original document responseTitle, description, canonical, robots tags and locationShows what this request initially/fully received
Browser DOM after completionSame elements and duplicatesShows changes after rendering
Documented HTML-limited bot requestResponse tags and timingTests your framework's user-agent handling
Google URL InspectionAvailable rendered output and crawl dateProvides 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.

Make your next SEO change a useful one.

The free tools are ready now. Our paid product is still in development; join the launch list for an update when it is available.