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.

Published May 24, 20267 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, preview URLs, paginated pages
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.

Verify with the Meta Tag Previewer

After shipping your metadata setup, run your most important pages through the RankCrab meta tag previewer to verify what Google and social platforms will actually see. The most common things to check:

  • Title length (50–60 characters for Google, up to 70 for Twitter)
  • Description length (150–160 characters)
  • OG image resolves to an absolute URL (not a relative path)
  • Canonical tag points to the right URL
Free tool
Meta tag + SERP previewer
See exactly how your title and meta description will render in Google.
Try it

For the full set of technical SEO checks on a live Next.js site, run an 80-check audit — it covers missing metadata, broken canonical chains, and duplicate <title> tags from next/head remnants in a partial migration.

This article is part of the SEO for Next.js cluster. Related reading:

Ship optimized content this afternoon.

7-day trial. $29 when it converts. Cancel from the dashboard the second it stops earning its keep.