| name | meta-tags |
| description | Meta tags, Open Graph, Twitter Cards, and Next.js Metadata API patterns. This skill should be used when the user asks about "meta tags", "Open Graph", "og:image", "Twitter Card", "social sharing preview", "generateMetadata", "metadata API", "canonical URL", "OG image generation", "opengraph-image.tsx", or needs to configure page-level metadata for SEO and social sharing. |
Meta Tags & Social Sharing
Next.js App Router provides a first-class Metadata API that replaces third-party packages like next-seo. Export a metadata object or generateMetadata function from any layout.tsx or page.tsx.
Static Metadata
Use for pages with fixed content (home, about, contact):
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'About Us',
description: 'Learn about our team and mission. We build tools for developers.',
openGraph: {
title: 'About Us',
description: 'Learn about our team and mission.',
url: '/about',
type: 'website',
images: [{ url: '/og/about.png', width: 1200, height: 630, alt: 'About Us' }],
},
twitter: {
card: 'summary_large_image',
},
alternates: {
canonical: '/about',
},
}
Dynamic Metadata (generateMetadata)
Use for pages with dynamic content (blog posts, products, profiles):
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 getPost(slug)
if (!post) return {}
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
url: `/blog/${slug}`,
type: 'article',
publishedTime: post.publishedAt,
modifiedTime: post.updatedAt,
authors: [post.author.name],
images: [{ url: post.image, width: , : , : post. }],
},
: {
: ,
: post.,
: post.,
: [post.],
},
: {
: ,
},
}
}
The fetch inside generateMetadata is automatically memoized with the page component — no duplicate requests.
Title Template
Set in the root layout. Child pages only provide the page-specific part:
export const metadata: Metadata = {
title: {
template: '%s | Acme',
default: 'Acme',
},
}
export const metadata: Metadata = {
title: 'About',
}
Nested layouts can override the template for their section:
export const metadata: Metadata = {
title: {
template: '%s | Acme Blog',
default: 'Acme Blog',
},
}
Open Graph Tags
Required Properties
| Property | Description | Length |
|---|
og:title | Page title for social sharing | 40-60 chars |
og:description | Summary for social cards | 150-200 chars |
og:image | Preview image URL (must be absolute) | - |
og:url | Canonical page URL | - |
og:type | Content type: website, article, product | - |
og:site_name | Your site name | - |
Image Specifications
| Platform | Size | Ratio | Format |
|---|
| Universal | 1200 x 630 px | 1.91:1 | PNG or JPG |
| Twitter/X large | 1200 x 675 px | 16:9 | PNG or JPG |
| Minimum | 600 x 315 px | 1.91:1 | PNG or JPG |
Use 1200 x 630 for universal compatibility. Keep file size under 300KB (max 8MB). URL must be absolute HTTPS.
Twitter Cards
twitter: {
card: 'summary_large_image',
title: 'Page Title',
description: 'Page description',
images: ['https://example.com/og.png'],
creator: '@username',
site: '@sitename',
}
If twitter fields are omitted, Next.js falls back to openGraph values automatically.
Dynamic OG Image Generation
Generate OG images on-the-fly with opengraph-image.tsx. No external service needed:
import { ImageResponse } from 'next/og'
export const alt = 'Blog post preview'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPost(slug)
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
height: '100%',
background: '#0a0a0a',
color: '#ffffff',
padding: 60,
}}
>
{post.title}
{post.excerpt}
),
{ ...size }
)
}
The image is auto-linked in the page's <head> — no manual openGraph.images needed when using file-based convention.
Custom Fonts in OG Images
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const font = await readFile(join(process.cwd(), 'assets/Inter-Bold.ttf'))
return new ImageResponse(
(<div style={{ fontSize: 48, fontFamily: 'Inter' }}>Title</div>),
{
...size,
fonts: [{ name: 'Inter', data: font, style: 'normal', weight: 700 }],
}
)
}
Twitter Image Convention
Similarly, create twitter-image.tsx for a Twitter-specific card image:
import { ImageResponse } from 'next/og'
export const alt = 'Site Name'
export const size = { width: 1200, height: 675 }
export const contentType = 'image/png'
export default function Image() {
return new ImageResponse(
(<div style={{ /* ... */ }}>Site Name</div>),
{ ...size }
)
}
Canonical URLs
Set canonical on every page to prevent duplicate content:
alternates: {
canonical: '/blog/my-post',
}
For paginated content:
alternates: {
canonical: '/blog',
}
Validation Checklist
After implementing metadata, verify:
- View source — check
<title>, <meta name="description">, OG tags in HTML
- Facebook Sharing Debugger — test OG preview (developers.facebook.com/tools/debug/)
- Twitter Card Validator — test card preview (cards-dev.twitter.com/validator)
- LinkedIn Post Inspector — test LinkedIn preview
- Google Rich Results Test — check structured data (separate from meta tags)
Common Mistakes
| Mistake | Fix |
|---|
Missing metadataBase | Set in root layout — OG images need absolute URLs |
Exporting both metadata and generateMetadata | Use only one per file — both cause a build error |
Relative og:image without metadataBase | Always set metadataBase or use absolute URLs |
| Description too long (>160 chars) | Keep 150-160 chars — Google truncates longer text |
| Title too long (>60 chars) | Keep 50-60 chars — search engines truncate |
| Same title/description on all pages | Each page needs unique metadata |