EmpireUI
Get Pro
← Blog8 min read#next.js#metadata#seo

Next.js Metadata API: The Right Way to Handle SEO in App Router

Stop hacking document.head — Next.js 13+ Metadata API handles Open Graph, Twitter Cards, and canonical URLs the right way. Here's the full breakdown.

Developer writing Next.js code on a laptop with dark theme editor

Why the Old Way Was Broken

If you've ever managed SEO in a Next.js pages-router project, you know the pain. You'd slap next/head into every page component, forget to add it in one route, and suddenly Google is indexing a page with no title and a blank og:image. It's brittle by default.

App Router, shipped in Next.js 13 (stable in 13.4), changes the contract completely. Metadata is now a first-class export from any layout.tsx or page.tsx — not a JSX side-effect. That distinction matters more than it sounds. The framework can statically extract your metadata at build time, merge it correctly from nested layouts, and generate the right head tags without you touching a single <head> element.

Honestly, this is one of the biggest quality-of-life improvements in the App Router migration. It's not flashy. Nobody tweets about metadata APIs. But when you stop hunting down missing og:title bugs at 11pm, you start to appreciate it.

That said, the API has a few gotchas that aren't obvious from the docs alone. This walkthrough covers the parts that actually trip people up in production — static metadata, dynamic metadata, Open Graph images, and the parts most tutorials skip entirely.

Static Metadata: The Basics Done Right

The simplest case is a static export. Drop a metadata object from any page or layout and you're done:

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

export const metadata: Metadata = {
  title: 'Blog | Empire UI',
  description: 'Design tips, component patterns, and frontend deep-dives.',
  openGraph: {
    title: 'Blog | Empire UI',
    description: 'Design tips, component patterns, and frontend deep-dives.',
    url: 'https://empire-ui.com/blog',
    siteName: 'Empire UI',
    images: [
      {
        url: 'https://empire-ui.com/og/blog.png',
        width: 1200,
        height: 630,
        alt: 'Empire UI Blog',
      },
    ],
    type: 'website',
  },
  twitter: {
    card: 'summary_large_image',
    title: 'Blog | Empire UI',
    description: 'Design tips, component patterns, and frontend deep-dives.',
    images: ['https://empire-ui.com/og/blog.png'],
  },
}

Worth noting: you don't need to repeat the base URL everywhere. Define a metadataBase in your root layout.tsx and Next.js will resolve all relative URLs automatically against it. That single setting saves you from about a dozen redundant string concatenations across your codebase:

// app/layout.tsx
export const metadata: Metadata = {
  metadataBase: new URL('https://empire-ui.com'),
  title: {
    template: '%s | Empire UI',
    default: 'Empire UI — React Component Library',
  },
}

The title.template field is underused. Set it in the root layout and every child page just provides a short title string — Next.js handles the suffix. No more "Page Title | Site Name | Site Name" double-suffix bugs from copy-pasting.

Dynamic Metadata: generateMetadata and Async Data

Static exports cover maybe 40% of real-world cases. The other 60% is dynamic routes — blog posts, product pages, user profiles. That's where generateMetadata comes in.

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

type Props = {
  params: { slug: string }
}

export async function generateMetadata(
  { params }: Props,
  parent: ResolvingMetadata
): Promise<Metadata> {
  const post = await fetchPost(params.slug)
  const previousImages = (await parent).openGraph?.images || []

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: [post.coverImage, ...previousImages],
    },
  }
}

The ResolvingMetadata parent parameter is the piece most people miss. It lets you inherit and extend metadata from parent layouts — so your blog post OG image can include the site-level fallback image as a secondary item if the post image is missing. In practice, you'll use this mainly for image fallbacks and to pull in site-wide defaults.

One more thing — Next.js automatically deduplicates the fetch for generateMetadata and your page's own data fetching. If you call fetchPost(params.slug) in both generateMetadata and the page component itself, it hits the network once. The request memoization is built-in, so don't be afraid to just call your fetch function in both places.

OG Images with next/og — Generated at the Edge

Static og:image paths work fine, but generating them dynamically from your actual content is a whole level better. Next.js ships ImageResponse from the next/og package, which runs at the edge and turns JSX into a PNG at request time.

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'

export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function Image({ params }: { params: { slug: string } }) {
  const post = await fetchPost(params.slug)

  return new ImageResponse(
    <div
      style={{
        background: 'linear-gradient(135deg, #0f0f11 0%, #1a1a2e 100%)',
        width: '100%',
        height: '100%',
        display: 'flex',
        flexDirection: 'column',
        justifyContent: 'flex-end',
        padding: '64px',
      }}
    >
      <p style={{ color: '#8b5cf6', fontSize: 20, margin: 0 }}>Empire UI Blog</p>
      <h1 style={{ color: '#ffffff', fontSize: 56, margin: '16px 0 0', lineHeight: 1.1 }}>
        {post.title}
      </h1>
    </div>,
    { ...size }
  )
}

The file-based convention (opengraph-image.tsx co-located with your page) is nice because Next.js auto-wires the URL into your metadata. You don't add anything to your metadata export — the framework sees the file and adds the og:image tag. Same pattern works for twitter-image.tsx, apple-icon.tsx, and icon.tsx.

Quick aside: ImageResponse uses a Satori-based renderer, which means it doesn't support all CSS. Flexbox works, grid doesn't, and font loading requires an explicit fonts array in the options. If you want custom typography on your OG images, fetch the font file as an ArrayBuffer and pass it in. It's a bit of extra setup but the results look sharp.

In practice, generated OG images dramatically improve click-through rates from social shares. An 1200x630px image with your actual post title looks infinitely better than a generic site screenshot. If you're building a blog or docs site and skipping this step, you're leaving real engagement on the table.

Canonical URLs, Robots, and the Stuff Everyone Forgets

Open Graph gets all the attention, but a few other metadata fields actually affect your search rankings more directly. Canonical URLs are the big one.

export const metadata: Metadata = {
  alternates: {
    canonical: '/blog/nextjs-metadata-seo',
    languages: {
      'en-US': '/en-US/blog/nextjs-metadata-seo',
      'fr-FR': '/fr-FR/blog/nextjs-metadata-seo',
    },
  },
  robots: {
    index: true,
    follow: true,
    googleBot: {
      index: true,
      follow: true,
      'max-video-preview': -1,
      'max-image-preview': 'large',
      'max-snippet': -1,
    },
  },
}

The robots field with explicit googleBot overrides is worth adding to your root layout. Without it, Google's crawler uses defaults — which are usually fine, but explicitly setting max-image-preview: large tells Google it's allowed to show your og:image in search results, not just in social cards. That's a real visibility difference in mobile search, particularly in 2026 when image carousels dominate above the fold.

Worth noting: the alternates.canonical field should use a relative path when you've set metadataBase. Next.js resolves it correctly. A lot of devs hardcode the full URL here, which works but creates duplicated domain strings everywhere. Relative paths are cleaner.

Don't forget the verification metadata either. Google Search Console, Bing Webmaster Tools, and others accept a meta verification tag:

export const metadata: Metadata = {
  verification: {
    google: 'your-google-verification-token',
    yandex: 'your-yandex-token',
  },
}

Paste those directly into your root layout metadata and you're done — no need for separate verification files, though Next.js supports those too.

Structured Data: The Part the Metadata API Doesn't Cover

Here's the one gap in the Metadata API: it doesn't handle JSON-LD structured data. Schema.org markup — the stuff that powers Google's rich results for articles, products, breadcrumbs, and FAQs — has to be injected manually as a <script> tag.

The recommended pattern is a tiny server component you drop into each page layout:

// components/JsonLd.tsx
export default function JsonLd({ data }: { data: Record<string, unknown> }) {
  return (
    <script
      type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
    />
  )
}

// Usage in a blog post page
export default async function BlogPost({ params }: Props) {
  const post = await fetchPost(params.slug)

  const articleSchema = {
    '@context': 'https://schema.org',
    '@type': 'Article',
    headline: post.title,
    datePublished: post.date,
    dateModified: post.updatedAt,
    author: { '@type': 'Person', name: post.author },
    image: post.coverImage,
  }

  return (
    <article>
      <JsonLd data={articleSchema} />
      {/* rest of the page */}
    </article>
  )
}

Look, it's a bit manual. But it works, it's server-rendered so crawlers see it immediately, and you keep full control over the schema shape. There are libraries that wrap this — next-seo being the most popular — but honestly for most projects a 10-line component is sufficient and adds zero dependencies.

If you're building with Empire UI, components like cards and pricing tables map cleanly to Schema.org types. A PricingTable component can emit an Offer schema alongside it. Your templates pages in particular would benefit from Product or SoftwareApplication structured data — that's the kind of markup that unlocks rich results in Google.

Putting It All Together: A Checklist

After shipping a few App Router projects, here's what a solid metadata setup actually looks like in practice. Not theoretical — the stuff that matters when you're ready to go live.

Root layout.tsx needs: metadataBase, title.template, site-level description, default OG image, twitter.card default, and the robots override with max-image-preview: large. That's your foundation. Every page inherits from it.

Each route segment (layouts and pages) should only override what's different: the specific title, description, og:image for that content. Don't repeat fields that are correctly inherited. Use generateMetadata for any page that gets its title and description from a data source.

Co-locate your opengraph-image.tsx and twitter-image.tsx next to pages that matter — landing pages, blog posts, product pages. Static pages can use a plain image URL in the metadata object instead.

Finally, validate everything. Paste a few URLs into the Open Graph Debugger (Facebook), LinkedIn's post inspector, and Google's Rich Results Test before you go live. You'll catch issues — especially malformed image dimensions (OG images want exactly 1200x630px, not approximately) — that don't show up in local dev. It's a 10-minute check that prevents embarrassing share previews for months.

If you're also building your UI with styled components or want to match your metadata to your visual brand, check out the glassmorphism generator for generating those signature backdrop-blur values, or the gradient generator to get exact CSS values that translate into your OG image backgrounds. Design and SEO aren't that separate when your brand consistency actually shows up in social shares.

FAQ

Can I use both the metadata export and generateMetadata in the same file?

No — it's one or the other per route segment. If you export both, Next.js throws a build error. Use generateMetadata whenever you need async data; use the static export for everything else.

Does Next.js automatically deduplicate metadata from nested layouts?

It merges them, but it doesn't deep-merge objects. If a child page exports openGraph, it completely replaces the parent's openGraph — it doesn't extend it. You need the parent ResolvingMetadata parameter in generateMetadata to manually merge.

What's the right og:image size for Next.js ImageResponse?

1200x630px is the standard. Twitter enforces a minimum of 300x157px for summary_large_image cards, but going with 1200x630 covers all platforms. Set it explicitly with export const size = { width: 1200, height: 630 } in your opengraph-image.tsx file.

Is next-seo still worth using with App Router?

Not really. The Metadata API covers everything next-seo did for pages-router, and it's built-in. next-seo v6 added App Router support but it's mostly a thin wrapper now. Save the dependency.

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

Read next

Next.js SEO in 2026: Metadata, sitemap.ts, JSON-LD and OG ImagesNext.js OG Image Generation: @vercel/og, Edge Runtime, Custom FontsPage Transitions in Next.js App Router: View Transitions APINext.js vs Remix in 2026: Which One Should You Use?