Blog/Next.js Metadata API: Complete SEO Guide (2026)
·9 min read

Next.js Metadata API: Complete SEO Guide (2026)

Master the Next.js Metadata API: export const metadata, generateMetadata, Open Graph, Twitter Cards, the metadataBase gotcha, JSON-LD, and common mistakes.

Check your site right now

Free SEO audit in 30 seconds — find all the issues covered in this guide.

Audit for free →

Why Next.js meta tags need special attention

Next.js gives you more control over your meta tags than almost any other framework — and that's exactly what makes it easy to get wrong. Unlike WordPress or Shopify, there's no admin UI to fall back on. If you don't wire up your metadata correctly in code, your pages go live with blank titles, missing Open Graph images, and broken social previews.

This guide covers everything: the App Router Metadata API introduced in Next.js 13, dynamic metadata with generateMetadata, Open Graph and Twitter Card setup, the notorious metadataBase bug that breaks og:image on every deployment, structured data with JSON-LD, and a full comparison with the old Pages Router approach.

Target keywords: nextjs metadata, next.js seo meta tags, nextjs opengraph, next.js metadata api.


1. The App Router Metadata API — export const metadata

In Next.js 13+ with the App Router, you export a metadata object from any layout.tsx or page.tsx. Next.js picks it up at build time and injects the correct tags into the automatically.

Here's a complete root layout example:

// app/layout.tsx

import type { Metadata } from 'next'

export const metadata: Metadata = {

title: {

default: 'My SaaS App',

template: '%s | My SaaS App',

},

description: 'The fastest way to do X without Y.',

metadataBase: new URL('https://myapp.com'),

openGraph: {

title: 'My SaaS App',

description: 'The fastest way to do X without Y.',

url: 'https://myapp.com',

siteName: 'My SaaS App',

images: [

{

url: '/og-image.png',

width: 1200,

height: 630,

alt: 'My SaaS App',

},

],

type: 'website',

},

twitter: {

card: 'summary_large_image',

title: 'My SaaS App',

description: 'The fastest way to do X without Y.',

images: ['/og-image.png'],

creator: '@yourhandle',

},

robots: {

index: true,

follow: true,

},

alternates: {

canonical: 'https://myapp.com',

},

}

export default function RootLayout({ children }: { children: React.ReactNode }) {

return (

{children}

)

}

The title.template key is especially useful: any page that exports title: 'Pricing' will automatically render as Pricing | My SaaS App — no manual concatenation needed.

Metadata defined in a child page.tsx overrides the parent layout's metadata for that route. Metadata defined in a parent layout.tsx applies to all routes beneath it unless overridden.


2. generateMetadata for dynamic pages

For routes with dynamic segments — like /blog/[slug] or /products/[id] — you can't use a static export const metadata. Instead, export an async generateMetadata function that receives the route params and returns a Metadata object.

// app/blog/[slug]/page.tsx

import type { Metadata } from 'next'

import { getPostBySlug } from '@/lib/posts'

interface Props {

params: Promise<{ slug: string }>

}

export async function generateMetadata({ params }: Props): Promise {

const { slug } = await params

const post = await getPostBySlug(slug)

if (!post) {

return {

title: 'Post Not Found',

}

}

return {

title: post.title,

description: post.excerpt,

openGraph: {

title: post.title,

description: post.excerpt,

url: https://myapp.com/blog/${slug},

type: 'article',

publishedTime: post.publishedAt,

authors: [post.authorName],

images: [

{

url: post.ogImage ?? '/og-default.png',

width: 1200,

height: 630,

},

],

},

alternates: {

canonical: https://myapp.com/blog/${slug},

},

}

}

export default async function BlogPost({ params }: Props) {

const { slug } = await params

const post = await getPostBySlug(slug)

// ...

}

Key points:

  • generateMetadata runs on the server at request time (or at build time for statically generated routes).
  • You can fetch data inside it — Next.js deduplicates identical fetches between generateMetadata and the page component automatically.
  • Always handle the null / not-found case to avoid returning an empty metadata object.
  • The params argument is a Promise in Next.js 15+, so always await it.


3. Open Graph tags in Next.js

Open Graph controls how your pages look when shared on social media — Facebook, LinkedIn, Slack, iMessage, Discord. The openGraph key in your metadata object maps directly to tags.

Static og:image

The simplest approach: put a 1200×630 image at public/og-image.png and reference it:

export const metadata: Metadata = {

openGraph: {

images: [

{

url: '/og-image.png', // relative — needs metadataBase set!

width: 1200,

height: 630,

alt: 'My App screenshot',

},

],

},

}

Dynamic og:image with opengraph-image.tsx

Next.js supports auto-generated OG images via a special file convention. Create app/opengraph-image.tsx (or app/blog/[slug]/opengraph-image.tsx for per-page images) and default-export a React component rendered with @vercel/og:

// app/opengraph-image.tsx

import { ImageResponse } from 'next/og'

export const runtime = 'edge'

export const alt = 'My SaaS App'

export const size = { width: 1200, height: 630 }

export const contentType = 'image/png'

export default function OgImage() {

return new ImageResponse(

(

style={{

background: 'black',

width: '100%',

height: '100%',

display: 'flex',

flexDirection: 'column',

alignItems: 'center',

justifyContent: 'center',

color: 'white',

fontFamily: 'sans-serif',

}}

>

My SaaS App

The fastest way to do X.

),

{ ...size }

)

}

Next.js will serve this at /opengraph-image and automatically wire up the og:image meta tag. For per-route images, the file goes in the route folder and can access route params via the params prop.


4. Twitter/X Card tags in Next.js

Twitter (now X) uses its own set of tags, separate from Open Graph. The twitter key in your metadata object covers these:

export const metadata: Metadata = {

twitter: {

card: 'summary_large_image', // shows a large image preview

site: '@myapphandle', // the site's Twitter account

creator: '@authorhandle', // the content author's account

title: 'My SaaS App',

description: 'The fastest way to do X without Y.',

images: ['https://myapp.com/og-image.png'],

},

}

Card types:

Almost all marketing pages should use summary_large_image. The image must be at least 800×418px and under 5MB. Twitter falls back to og:image if twitter:image is not set — but set it explicitly to avoid surprises.


5. Canonical URLs with alternates

A canonical URL tells search engines which version of a page is the authoritative one — critical when the same content is accessible at multiple URLs (e.g., with and without trailing slashes, with UTM parameters, or via pagination).

export const metadata: Metadata = {

alternates: {

canonical: 'https://myapp.com/blog/my-post',

languages: {

'en-US': 'https://myapp.com/blog/my-post',

'fr-FR': 'https://fr.myapp.com/blog/my-post',

},

},

}

This generates