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 — 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:
generateMetadataruns 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
generateMetadataand the page component automatically. - Always handle the
null/ not-found case to avoid returning an empty metadata object. - The
paramsargument is a Promise in Next.js 15+, so alwaysawaitit.
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:
summary— small square image thumbnail (default)summary_large_image— large landscape image, much higher click-throughapp— for mobile app deep linksplayer— for video/audio embeds
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 in the .
For dynamic routes, generate the canonical inside generateMetadata using the slug:
alternates: {
canonical: https://myapp.com/blog/${slug},
},
Never omit canonical tags on pages that could be reached at more than one URL. Google will pick one of the duplicates at random if no canonical is specified — and it might not pick the one you want.
6. The metadataBase gotcha (the most common Next.js SEO mistake)
This is the single most common bug we see when auditing Next.js sites. Here's the scenario:
You set your og:image to '/og-image.png' — a relative URL. You deploy. You check Facebook's sharing debugger. The og:image shows as http://localhost:3000/og-image.png or just /og-image.png. Facebook can't load it. Your social previews are broken everywhere.
The cause: Next.js needs to know your site's base URL to convert relative image paths to absolute URLs. Without metadataBase, it falls back to localhost:3000 in production builds — or leaves paths relative.
The fix: Set metadataBase in your root layout.tsx:
// app/layout.tsx
export const metadata: Metadata = {
metadataBase: new URL('https://myapp.com'),
openGraph: {
images: ['/og-image.png'], // now resolves to https://myapp.com/og-image.png
},
}
You can also use an environment variable so it works across environments:
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL ?? 'https://myapp.com'),
Set NEXT_PUBLIC_SITE_URL=https://myapp.com in your Vercel environment variables and NEXT_PUBLIC_SITE_URL=http://localhost:3000 in .env.local. Done — relative image URLs will always resolve correctly.
7. Viewport and robots meta tags
Viewport
In Next.js App Router, the viewport meta tag is no longer part of the Metadata type — it has its own export:
// app/layout.tsx
import type { Viewport } from 'next'
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
maximumScale: 1,
themeColor: '#000000',
}
This generates and . If you try to put viewport inside the metadata object, Next.js will warn you and ignore it.
Robots
Control indexing at the page level via the robots key:
export const metadata: Metadata = {
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
}
For pages you don't want indexed (thank-you pages, admin routes, preview pages):
export const metadata: Metadata = {
robots: { index: false, follow: false },
}
You can also create a app/robots.ts file to generate your robots.txt programmatically:
// app/robots.ts
import type { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/api/', '/admin/'],
},
sitemap: 'https://myapp.com/sitemap.xml',
}
}
8. Structured data / JSON-LD in Next.js
Structured data (schema markup) helps Google understand your content and can unlock rich results in search — star ratings, FAQs, breadcrumbs, product prices. Next.js doesn't have a built-in API for JSON-LD, but it's trivial to add via a tag in your component:
// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }: Props) {
const { slug } = await params
const post = await getPostBySlug(slug)
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: post.title,
description: post.excerpt,
datePublished: post.publishedAt,
dateModified: post.updatedAt,
author: {
'@type': 'Person',
name: post.authorName,
},
image: https://myapp.com${post.ogImage},
url: https://myapp.com/blog/${slug},
}
return (
<>