Next.js App Router ships with a first-class Metadata API that makes it straightforward to add Open Graph tags — no manual <head> manipulation needed. This guide covers static metadata, dynamic metadata for data-fetched pages, and auto-generated OG images using the opengraph-image.tsx convention.
Static Metadata in layout.tsx or page.tsx
Export a metadata object from any page or layout. Next.js merges these up the tree, so a root layout provides defaults and individual pages override what they need:
// app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: {
default: 'OGProof',
template: '%s — OGProof',
},
description: 'Validate Open Graph tags across 6 social crawlers.',
openGraph: {
title: 'OGProof',
description: 'Validate Open Graph tags across 6 social crawlers.',
url: 'https://ogproof.veridux.ai',
siteName: 'OGProof',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'OGProof',
description: 'Validate Open Graph tags across 6 social crawlers.',
},
}Any page.tsx that doesn't export its own metadata will inherit the layout's values. Pages that do export metadata get their values merged on top — so you only override what changes.
Dynamic Metadata for Data-Fetched Pages
For pages where the title and description come from a database or API, export generateMetadata instead of a static object. Next.js calls this function at request time (or build time for SSG):
// 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 getPost(slug)
if (!post) return { title: 'Not Found' }
return {
title: post.title,
description: post.description,
openGraph: {
title: post.title,
description: post.description,
url: `https://example.com/blog/${slug}`,
type: 'article',
publishedTime: new Date(post.publishedAt).toISOString(),
},
twitter: {
card: 'summary_large_image',
title: post.title,
description: post.description,
},
}
}Auto-Generated OG Images with opengraph-image.tsx
Instead of creating a 1200×630 PNG for every page, use the opengraph-image.tsx file convention. Next.js uses Satori to render your React component to an image at build time or on the edge:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
interface Props {
params: { slug: string }
}
export default async function OGImage({ params }: Props) {
const post = await getPost(params.slug)
return new ImageResponse(
(
<div
style={{
background: '#0a0a14',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
padding: 64,
fontFamily: 'sans-serif',
}}
>
<div style={{ fontSize: 14, color: '#7c3aed', marginBottom: 16 }}>
YOUR SITE · BLOG
</div>
<div style={{ fontSize: 52, fontWeight: 700, color: 'white', lineHeight: 1.2 }}>
{post.title}
</div>
<div style={{ fontSize: 20, color: 'rgba(255,255,255,0.5)', marginTop: 20 }}>
{post.description}
</div>
</div>
),
{ ...size }
)
}Satori has one important constraint: it supports only inline styles, not CSS classes. Tailwind class names will not work inside ImageResponse. Everything must be written as a style={{ }} object using CSS-in-JS syntax. Also avoid HTML tags that Satori doesn't support — <br />, <svg> with external hrefs, and some form elements.
The Metadata Waterfall
Next.js merges metadata from outermost layout to innermost page. The merge rules are:
- 1.Root layout (app/layout.tsx) sets the baseline for all pages.
- 2.Nested layouts (app/blog/layout.tsx) override the root for their subtree.
- 3.Individual pages (app/blog/[slug]/page.tsx) override their layout for that route only.
- 4.Fields are merged shallowly at the top level — if a page exports openGraph, the entire openGraph object from the parent is replaced, not merged field by field.
Validating Your Next.js OG Tags
After adding OG tags, you need to verify what crawlers actually see — not what your browser renders. Social crawlers don't run JavaScript and they ignore cookies and auth. They fetch the raw HTML from the server.
The fastest way to validate is OGProof: paste your URL and it fetches server-side, parses every OG tag, and renders accurate previews for Twitter, LinkedIn, Facebook, Discord, Slack, and iMessage. It also validates each tag against each platform's documented constraints and tells you exactly what to fix.
- —Checks that og:image is an absolute HTTPS URL
- —Validates image dimensions against per-platform minimums
- —Flags titles and descriptions that will be truncated
- —Detects missing twitter:card that causes Twitter to use a small thumbnail
- —Shows the actual HTML the crawler sees, not the JavaScript-rendered DOM