EmpireUI
Get Pro
← Blog8 min read#nextjs#seo#metadata

Next.js SEO Metadata: The Complete App Router Reference

Stop guessing at Next.js App Router metadata. This reference covers generateMetadata, static exports, Open Graph, Twitter cards, and canonical URLs with real code.

Code editor showing Next.js metadata configuration on a dark screen

Why Next.js App Router Metadata Is Nothing Like Pages Router

Honestly, if you're copying metadata patterns from a Next.js 12 tutorial in 2026, you're setting yourself up for hours of head-scratching. The App Router completely replaced next/head with a dedicated metadata API, and the two don't mix. You can't drop a <Head> component into a Server Component and expect it to work.

In the old Pages Router, you'd shove <title>, <meta>, and Open Graph tags inside a <Head> block on every page. It worked, but it was messy — especially when you had dynamic routes and needed to fetch data just to build the right title. The App Router's metadata API solves that cleanly by letting you export a static metadata object or an async generateMetadata function directly from any page.tsx or layout.tsx file.

The result is that metadata is now co-located with the route itself. No more hunting through a custom _document.tsx or trying to remember which <Head> takes priority. If you've ever worked on a large Next.js app where three different layouts were fighting over the page title, you'll appreciate how much cleaner this is.

Static Metadata Export: The Simplest Starting Point

For pages where the title and description don't change based on data, the static metadata export is all you need. It's just a typed object — Next.js picks it up at build time and injects it into the <head> automatically.

// app/about/page.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  title: 'About Us — Empire UI',
  description: 'Learn about Empire UI, a free open-source React component library with 40 visual styles built on Tailwind CSS.',
  openGraph: {
    title: 'About Us — Empire UI',
    description: 'Free React + Tailwind components with 40 visual styles.',
    url: 'https://empire-ui.com/about',
    siteName: 'Empire UI',
    images: [
      {
        url: 'https://empire-ui.com/og/about.png',
        width: 1200,
        height: 630,
        alt: 'Empire UI about page',
      },
    ],
    locale: 'en_US',
    type: 'website',
  },
  twitter: {
    card: 'summary_large_image',
    title: 'About Us — Empire UI',
    description: 'Free React + Tailwind components with 40 visual styles.',
    images: ['https://empire-ui.com/og/about.png'],
  },
};

export default function AboutPage() {
  return <main>...</main>;
}

Notice that Open Graph and Twitter card data don't auto-inherit from the top-level fields. You have to repeat yourself a bit. That's annoying but intentional — different platforms parse these tags differently, and Next.js doesn't want to make assumptions about what value you want where.

generateMetadata for Dynamic Routes

Dynamic routes are where the metadata API really earns its keep. Instead of exporting a static object, you export an async function called generateMetadata. It receives the same params and searchParams as your page component, so you can fetch whatever you need and build the metadata from real data.

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';

interface Props {
  params: Promise<{ slug: string }>;
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await fetch(`https://api.empire-ui.com/blog/${slug}`, {
    next: { revalidate: 3600 },
  }).then((r) => r.json());

  if (!post) {
    return { title: 'Post Not Found' };
  }

  return {
    title: `${post.title} — Empire UI Blog`,
    description: post.excerpt,
    alternates: {
      canonical: `https://empire-ui.com/blog/${slug}`,
    },
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.date,
      authors: ['Empire UI'],
      images: [{ url: post.image, width: 1200, height: 630 }],
    },
  };
}

One thing worth knowing: Next.js deduplicates fetch calls between generateMetadata and the page component itself, so hitting the same endpoint in both won't double your network requests. React's fetch cache handles it. That said, if you're using a database client rather than fetch, you'll want to extract the query into a shared function so you're not running it twice.

Also note that params is now a Promise in Next.js 15+. If you're on 14 or below, it's a plain object. Check your package.json — if you're running next@15.x.x or later, you need to await params.

Metadata Inheritance and the Layout Cascade

Here's the thing: metadata in Next.js App Router works as a cascade, not a merge. Each segment can define its own metadata, and child segments override parent values field by field — not deeply. So if your root layout.tsx defines an openGraph object with six fields, and your page only overrides openGraph.title, the other five fields don't carry over. You get just the title.

This catches a lot of developers off guard. The fix is to use the metadataBase option in your root layout and rely on the title.template feature for consistent titling without duplication:

// app/layout.tsx
import type { Metadata } from 'next';

export const metadata: Metadata = {
  metadataBase: new URL('https://empire-ui.com'),
  title: {
    default: 'Empire UI — React Components',
    template: '%s | Empire UI',
  },
  description: 'Free open-source React component library with 40 visual styles.',
  openGraph: {
    siteName: 'Empire UI',
    locale: 'en_US',
    type: 'website',
  },
};

With metadataBase set, you can use relative URLs everywhere else — /og/about.png instead of https://empire-ui.com/og/about.png. That's a small convenience but it pays off when you're managing dozens of routes.

The title.template string works with the %s placeholder. When a page sets title: 'About Us', the rendered title becomes About Us | Empire UI. If you want the root layout's title to show on pages that don't set their own, that's what title.default is for. It won't apply the template to itself.

Canonical URLs and alternates: Stop Leaking Duplicate Content

Canonical tags matter more than most developers realize. Without them, Google can and will index multiple URLs for the same page — ?ref=twitter, ?utm_source=newsletter, the www vs non-www version — and spread your ranking signals across them instead of concentrating them on the URL you actually want ranked.

Next.js handles canonical URLs through the alternates field. You can set a canonical URL per page, and also declare hreflang alternates for multilingual sites. Combined with metadataBase, you can use relative paths:

export const metadata: Metadata = {
  alternates: {
    canonical: '/blog/nextjs-seo-metadata-complete',
    languages: {
      'en-US': '/blog/nextjs-seo-metadata-complete',
      'fr-FR': '/fr/blog/nextjs-seo-metadata-complete',
    },
  },
};

Setting canonical URLs on every dynamic route is something a lot of teams skip because it feels tedious. Don't skip it. If you're generating these pages from a CMS or a JSON data file, you can write the canonical URL once in generateMetadata based on the slug, and it'll be consistent across every page automatically.

robots.txt, sitemap.ts, and the Files You're Missing

The metadata object handles per-page signals, but the App Router also ships with file-based conventions for site-wide SEO files. You probably already know about robots.txt, but did you know Next.js 13.3+ lets you generate it dynamically from a robots.ts file in your app directory?

Same story for sitemaps. Instead of a static sitemap.xml that goes stale the moment you publish a new post, you write a sitemap.ts file that fetches your routes at request time (or build time with generateStaticParams). It exports an array of MetadataRoute.Sitemap objects — each with a url, lastModified, changeFrequency, and priority. Next.js serializes it to valid XML automatically.

There's also opengraph-image.tsx and twitter-image.tsx. Drop these files next to a page.tsx and Next.js will use them as default social images for that route and all its children. They're React components rendered server-side using @vercel/og under the hood — you can generate dynamic OG images without a separate API route. That was a pain to set up manually in Next.js 12. Now it's genuinely straightforward.

Structured Data and JSON-LD: What the Metadata API Doesn't Cover

The Next.js metadata API doesn't include built-in support for JSON-LD structured data. For articles, products, FAQs, or breadcrumbs that you want Google to understand as rich results, you'll need to inject the <script type="application/ld+json"> tag yourself. The right way to do it in the App Router is a Script component or a plain script tag in your Server Component.

// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: Props) {
  const { slug } = await params;
  const post = await getPost(slug);

  const jsonLd = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    description: post.excerpt,
    datePublished: post.date,
    author: { '@type': 'Organization', name: 'Empire UI' },
    image: post.image,
    url: `https://empire-ui.com/blog/${slug}`,
  };

  return (
    <>
      <script
        type="application/ld+json"
        dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
      />
      <article>...</article>
    </>
  );
}

It's worth pairing this with a solid theme toggle implementation if your site supports dark mode — the structured data doesn't change between themes, but your OG image generation might need to account for it.

For component-heavy UIs, structured data is often an afterthought. But if you're building something like a glassmorphism UI showcase page or a component library docs site, Article or WebPage schema can meaningfully boost click-through rates by enabling rich snippets. That's not marketing copy — Google's own documentation backs it up with CTR data.

Common Pitfalls and Debugging Metadata in App Router

Why doesn't my Open Graph image show up on Twitter? Probably because you're testing against a cached card. Twitter's card validator caches aggressively, and so does LinkedIn. Always use the respective platform's developer tools to force a fresh fetch when debugging — don't assume the metadata is wrong just because the preview didn't update.

Another common issue: metadata not appearing in dev mode. Next.js renders metadata differently in development versus production. The <head> output looks different in the browser DevTools because Next.js uses a client-side script to inject some tags during development. If you're not sure whether a tag is being rendered, check curl -s http://localhost:3000/your-page | grep -i 'og:title' instead of relying on the browser inspector.

If you want to go deeper on performance while handling all this data fetching in your components, the React performance guide covers memoization and Suspense patterns that apply directly to pages that use generateMetadata with multiple async calls. And if you're pairing this with complex forms, React Hook Form patterns has you covered for the client-side layer without adding bundle weight to your server-rendered metadata.

One last thing: don't forget metadataBase. It's the most commonly omitted setting and it causes every relative URL in your Open Graph images to render as http://localhost:3000/your-image.png in production builds if you only set it conditionally. Set it once in the root layout unconditionally, pointed at your production domain.

FAQ

Can I use next/head in the App Router?

No. The next/head component is Pages Router only. In the App Router, you export a metadata object or a generateMetadata function from page.tsx or layout.tsx. Mixing the two will cause hydration warnings and unreliable behavior.

Does metadata from a parent layout automatically merge into child pages?

Only at the field level, not deeply. If a parent layout defines an openGraph object and a child page also defines openGraph, the child's object completely replaces the parent's — it doesn't merge the individual keys. You need to repeat any shared Open Graph fields you want on the child page.

How do I generate dynamic OG images in Next.js App Router?

Create an opengraph-image.tsx file next to your page.tsx. Export a React component that renders JSX — Next.js uses @vercel/og under the hood to convert it to a PNG at request time. You can pass route params to it via the standard params prop to generate per-post or per-product images dynamically.

Why is `params` a Promise in generateMetadata in Next.js 15?

Next.js 15 made params and searchParams async to align with the new rendering model and to enable better caching. If you're on Next.js 14 or earlier, params is a plain object. On 15+, you need to await params before destructuring. Check your next version in package.json to know which behavior applies.

Where should I put JSON-LD structured data in the App Router?

Render a <script type="application/ld+json"> tag directly in your Server Component using dangerouslySetInnerHTML. Since this runs server-side, there's no hydration issue. Don't use next/script with strategy="afterInteractive" for JSON-LD — it needs to be in the initial HTML response for search engines to find it.

How do I set a default title that also templates child page titles?

Use the title object in your root layout.tsx with both default and template properties. Set template: '%s | Your Site Name' and default: 'Your Site Name'. Child pages that set a string title will have %s replaced with their title. Pages that don't set a title will show the default value without any template applied.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Next.js Performance Checklist 2026: Every Optimization, RankedReact Server Actions: Complete Guide for Next.js App RouterNext.js App Router vs Pages Router: Which to Use in 2026Framer Motion Page Transitions: Full Next.js App Router Example