Next.js 14 ▲ Metadata and OG Images — Static/dynamic metadata & social cards
Next.js App Router treats metadata as a server-side concern. A page or layout exports either a static metadata object or an async generateMetadata function, and Next.js renders the corresponding <title>, <meta>, and <link> tags into the HTML <head>. For social sharing, the framework provides a file convention: an opengraph-image file in a route segment becomes the Open Graph image for that segment, and an opengraph-image.tsx file can generate that image dynamically from JSX using the ImageResponse constructor.
This chapter covers the two metadata APIs, the file-based conventions for Open Graph and Twitter images, and the boundary between them. It explains when to use a static object versus a dynamic function, how metadata is inherited across route segments, how to generate per-post social cards with ImageResponse, and the constraints that ImageResponse imposes on layout and assets. By the end, you will be able to give every route a title, description, and share image that reflects its actual content.
Key point: The metadata object and generateMetadata function are only supported in Server Components. The opengraph-image file convention takes precedence over the openGraph.images field in a metadata object, because the file is colocated with the route and Next.js can verify that it exists.
Why metadata has two APIs
The static problem. A route whose title and description never change can declare them as a plain object. This is the simplest case: a blog index page, an about page, a documentation landing page. The metadata export is evaluated once at build time, and the values are rendered into the static shell. There is no data fetching and no async work .
The dynamic problem. A route whose metadata depends on the content of the page cannot use a static object. A blog post’s title is the post’s title; its description is the post’s excerpt; its OG image should show the post’s title. The generateMetadata function is async and receives the route’s params and searchParams. It fetches the data it needs and returns a Metadata object. Next.js calls it during rendering and injects the result into the head .
The social image problem. A static opengraph-image.jpg works for a route with a fixed identity. A route with hundreds of dynamic pages—product pages, blog posts, user profiles—needs an image that varies per page. The opengraph-image.tsx file convention solves this: the file default-exports an async function that receives the route’s params and returns an ImageResponse. The image is generated at build time for static routes or at request time for dynamic routes .
a. Static metadata
A layout.tsx or page.tsx can export a metadata object. The object uses the Metadata type from next, and Next.js maps its fields to the appropriate head tags.
// app/blog/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'My Blog',
description: 'Articles about web development and systems.',
openGraph: {
title: 'My Blog',
description: 'Articles about web development and systems.',
type: 'website',
},
}
export default function BlogLayout({ children }: { children: React.ReactNode }) {
return <section>{children}</section>
}
Metadata is inherited. A page inside app/blog/ inherits the layout’s metadata and can override specific fields. The title field has a template mechanism: a layout can define title: { default: 'Acme', template: '%s | Acme' }, and a child page’s title: 'About' becomes About | Acme in the rendered <title> .
Two meta tags are always rendered regardless of configuration: the charset and viewport tags. The viewport option inside metadata was deprecated in Next.js 14; viewport configuration moved to a separate viewport export .
b. Dynamic metadata with generateMetadata
The generateMetadata function is async and receives the route’s params. It returns a Metadata object, and it can fetch data to construct that object.
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
type Props = {
params: Promise<{ slug: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) => r.json())
return {
title: post.title,
description: post.excerpt,
openGraph: {
title: post.title,
description: post.excerpt,
type: 'article',
},
}
}
export default async function Page({ params }: Props) {
const { slug } = await params
const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) => r.json())
return <article>{post.content}</article>
}
The fetch inside generateMetadata and the fetch inside the page are the same request. Next.js memoizes fetch requests, so the data is fetched once and reused. For database queries or other non-fetch data sources, React’s cache function achieves the same result: the query runs once, and both the metadata function and the page component receive the cached value .
The second argument to generateMetadata is parent: ResolvingMetadata, a promise that resolves to the metadata of the parent segment. This allows a child route to extend the parent’s Open Graph images rather than replacing them .
c. File-based OG images
The opengraph-image convention works in two modes. A literal image file (opengraph-image.jpg, .png, .gif) is used as-is. Next.js detects the file and adds the og:image meta tag automatically. An accompanying opengraph-image.alt.txt file provides the alt text .
The generated mode uses a opengraph-image.tsx file that default-exports a function returning an ImageResponse. The function receives the route’s params, which means it can generate a different image for each dynamic route.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/app/lib/data'
export const alt = 'Blog post cover'
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={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: 80,
background: 'white',
}}
>
<div style={{ fontSize: 72, fontWeight: 'bold' }}>{post.title}</div>
<div style={{ fontSize: 32, color: '#666', marginTop: 24 }}>{post.author}</div>
</div>
),
{ ...size }
)
}
The size export is optional but recommended. It sets the og:image:width and og:image:height meta tags, which social platforms use to reserve layout space before the image loads . The contentType export sets the image MIME type. The alt export sets the og:image:alt text .
The twitter-image convention mirrors opengraph-image for Twitter cards. If only opengraph-image exists, Twitter falls back to it. A separate twitter-image is only necessary when the card should differ from the Open Graph image .
d. The ImageResponse constraints
ImageResponse is not a browser. It uses Satori and Resvg to convert JSX and CSS into a PNG, and it supports only a subset of CSS. Flexbox works; display: grid does not. Absolute positioning works. Custom fonts can be loaded, but only ttf, otf, and woff formats are accepted, and the total bundle size—JSX, CSS, fonts, and images—must stay under 500 KB .
// Loading a custom font for the generated image
import { ImageResponse } from 'next/og'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
const fontData = await readFile(join(process.cwd(), 'assets/Inter-SemiBold.ttf'))
export default async function Image() {
return new ImageResponse(
<div style={{ display: 'flex', fontSize: 64 }}>Hello</div>,
{
width: 1200,
height: 630,
fonts: [{ name: 'Inter', data: fontData, weight: 600, style: 'normal' }],
}
)
}
The default dimensions are 1200×630, which is the Open Graph recommendation. The ImageResponse constructor accepts width, height, fonts, and emoji options. The generated image is cached by default unless the function uses request-time APIs or uncached data, which makes it statically optimized for routes whose params are known at build time .
Complete Example Session
// ============================================
// PART 1: STATIC METADATA IN A LAYOUT
// ============================================
// app/blog/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: { default: 'Blog', template: '%s | Blog' },
description: 'Articles about web development.',
}
// The template applies to child pages.
// A page with title: 'About' renders "About | Blog".
// ============================================
// PART 2: STATIC METADATA IN A PAGE
// ============================================
// app/blog/page.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'All Posts',
description: 'Browse all articles.',
}
// Inherits the template from the layout.
// Renders "All Posts | Blog".
// ============================================
// PART 3: DYNAMIC METADATA
// ============================================
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
type Props = { params: Promise<{ slug: string }> }
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const post = await fetch(`https://api.example.com/posts/${slug}`).then((r) => r.json())
return {
title: post.title,
description: post.excerpt,
}
}
// The fetch is memoized if the page also fetches the same URL.
// ============================================
// PART 4: MEMOIZING A DATABASE QUERY
// ============================================
// app/lib/data.ts
import { cache } from 'react'
import { db } from '@/app/lib/db'
export const getPost = cache(async (slug: string) => {
return db.post.findUnique({ where: { slug } })
})
// Both generateMetadata and the page call getPost(slug).
// The query runs once.
// ============================================
// PART 5: STATIC OG IMAGE FILE
// ============================================
// app/opengraph-image.jpg (1200x630)
// app/opengraph-image.alt.txt (alt text)
// Next.js adds:
// <meta property="og:image" content="/opengraph-image.jpg" />
// <meta property="og:image:alt" content="..." />
// ============================================
// PART 6: GENERATED OG IMAGE
// ============================================
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/app/lib/data'
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', fontSize: 72 }}>{post.title}</div>,
{ ...size }
)
}
// ============================================
// PART 7: CUSTOM FONT IN ImageResponse
// ============================================
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
const font = await readFile(join(process.cwd(), 'assets/Inter-Bold.ttf'))
new ImageResponse(<div style={{ fontFamily: 'Inter' }}>Title</div>, {
width: 1200,
height: 630,
fonts: [{ name: 'Inter', data: font, weight: 700, style: 'normal' }],
})
// ============================================
// PART 8: TWITTER IMAGE FALLBACK
// ============================================
// app/blog/[slug]/twitter-image.tsx
// If this file is absent, Twitter uses opengraph-image.
// Add it only when the card should differ.
// ============================================
// PART 9: METADATA BASE FOR RELATIVE URLS
// ============================================
export const metadata: Metadata = {
metadataBase: new URL('https://acme.com'),
openGraph: {
images: '/og-image.png', // resolves to https://acme.com/og-image.png
},
}
// ============================================
// PART 10: VERIFY THE OUTPUT
// ============================================
// Open browser dev tools and inspect <head>.
// Confirm og:title, og:description, og:image.
// Share the URL in a social platform preview tool.
The ten parts covered static layout metadata, static page metadata, dynamic metadata, memoization, static OG files, generated OG images, custom fonts, Twitter fallback, metadata base, and verification.
Quick Reference
Metadata APIs
| API | Location | Use case |
|---|---|---|
export const metadata | layout.tsx or page.tsx | Static values |
export async function generateMetadata | layout.tsx or page.tsx | Values from data |
opengraph-image.jpg | Route segment | Static OG image |
opengraph-image.tsx | Route segment | Generated OG image |
twitter-image.jpg / .tsx | Route segment | Twitter card image |
Key metadata fields
| Field | Purpose |
|---|---|
title | Page title (string or template object) |
description | Meta description |
openGraph | OG title, description, images, type |
twitter | Twitter card type, title, images |
metadataBase | Base URL for relative image paths |
ImageResponse options
| Option | Default | Purpose |
|---|---|---|
width | 1200 | Image width |
height | 630 | Image height |
fonts | — | Custom font array |
emoji | 'twemoji' | Emoji set |
OG image constraints
| Constraint | Limit |
|---|---|
| Max file size (OG) | 8 MB |
| Max file size (Twitter) | 5 MB |
| ImageResponse bundle | 500 KB |
| Supported fonts | ttf, otf, woff |
| CSS support | Flexbox, absolute positioning (no grid) |
Best Practices
✅ Do This:
// Use a title template in the root layout
title: { default: 'Acme', template: '%s | Acme' } # ✅
// Memoize data shared between generateMetadata and the page
export const getPost = cache(async (slug) => { ... }) # ✅
// Use opengraph-image.tsx for dynamic routes
// One file generates a card per post # ✅
// Set metadataBase for absolute OG image URLs
metadataBase: new URL('https://acme.com') # ✅
❌ Don’t Do This:
// Export both metadata and generateMetadata from the same file
export const metadata = {}; export async function generateMetadata() {} # ❌
// Use display: grid inside ImageResponse
style={{ display: 'grid' }} # ❌ (unsupported)
// Skip metadataBase and use relative OG image paths
openGraph: { images: '/og.png' } // resolves incorrectly # ❌
// Fetch the same data twice without memoization
// generateMetadata and page each run the query # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| OG image not showing in preview | Relative URL without metadataBase | Add metadataBase to the metadata |
| Generated image is blank | CSS uses unsupported properties | Use flexbox only; check Satori limitations |
| Metadata not updating | Static metadata used for dynamic route | Switch to generateMetadata |
| Duplicate data fetch | No cache() or fetch memoization | Wrap the query in React’s cache |
| Image generation exceeds 500 KB | Large fonts or images in bundle | Reduce assets; use runtime fetching |
| Twitter card shows wrong image | twitter-image missing and opengraph-image incompatible | Add a twitter-image file |
Real-World Examples
1. Root layout title template
export const metadata = { title: { default: 'Acme', template: '%s | Acme' } }
2. Static page metadata
export const metadata = { title: 'About', description: 'About Acme' }
3. Dynamic metadata
export async function generateMetadata({ params }) {
const post = await getPost((await params).slug)
return { title: post.title }
}
4. Memoized query
export const getPost = cache(async (slug) => db.post.findUnique({ where: { slug } }))
5. Static OG image
app/opengraph-image.jpg
app/opengraph-image.alt.txt
6. Generated OG image
// app/blog/[slug]/opengraph-image.tsx
export default async function Image({ params }) { return new ImageResponse(<div />, { width: 1200, height: 630 }) }
7. Custom font in image
fonts: [{ name: 'Inter', data: fontData, weight: 700, style: 'normal' }]
8. metadataBase
metadataBase: new URL('https://acme.com')
9. Twitter fallback
# Only create twitter-image.tsx if the card differs from OG
10. Verify head tags
# Inspect <head> in dev tools for og:title, og:image
Visual
Metadata resolution flow
┌─────────────────────────────────────────────────────────────┐
│ METADATA RESOLUTION │
│ │
│ Root layout metadata │
│ │ │
│ ▼ │
│ Segment layout metadata (inherited + overridden) │
│ │ │
│ ▼ │
│ Page metadata (static or generateMetadata) │
│ │ │
│ ▼ │
│ File-based metadata (opengraph-image) takes precedence │
│ │ │
│ ▼ │
│ Rendered <head> tags │
└─────────────────────────────────────────────────────────────┘
Static vs dynamic metadata
┌─────────────────────────────────────────────────────────────┐
│ STATIC DYNAMIC │
│ │
│ export const metadata export async function │
│ Values known at build generateMetadata() │
│ No data fetching Fetches data │
│ Simple pages Dynamic routes │
│ │
│ Use for: About, Index Use for: [slug], [id] │
└─────────────────────────────────────────────────────────────┘
OG image precedence
┌─────────────────────────────────────────────────────────────┐
│ OG IMAGE SOURCES │
│ │
│ 1. opengraph-image.jpg / .tsx in the segment │
│ └── Highest priority (colocated, verified) │
│ │
│ 2. openGraph.images in metadata object │
│ └── Used if no file exists │
│ │
│ 3. Parent segment's opengraph-image │
│ └── Inherited if child has none │
└─────────────────────────────────────────────────────────────┘
ImageResponse pipeline
┌─────────────────────────────────────────────────────────────┐
│ IMAGERESPONSE │
│ │
│ JSX + CSS │
│ │ │
│ ▼ │
│ Satori (HTML/CSS → SVG) │
│ │ │
│ ▼ │
│ Resvg (SVG → PNG) │
│ │ │
│ ▼ │
│ PNG response (1200×630 default) │
│ │
│ Constraints: flexbox only, 500KB bundle, ttf/otf/woff │
└─────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Static metadata | export const metadata: Metadata |
| Dynamic metadata | export async function generateMetadata |
| Component restriction | Server Components only |
| Title template | title: { default, template } |
| Static OG image | opengraph-image.jpg |
| Generated OG image | opengraph-image.tsx + ImageResponse |
| Twitter image | twitter-image.jpg / .tsx |
| ImageResponse defaults | 1200×630 |
| ImageResponse CSS | Flexbox only |
| Bundle limit | 500 KB |
Key takeaways:
- Static metadata is for values that do not change;
generateMetadatais for values that do. The static object is evaluated at build time. The async function receives params and can fetch data. Both are Server Component exports, and only one can exist per route segment . - Metadata is inherited across segments. A layout’s title template applies to child pages. A page can override specific fields without redefining the entire object. The
parentargument togenerateMetadataallows extending the parent’s values . - The
opengraph-imagefile convention takes precedence overopenGraph.imagesin a metadata object. The file is colocated with the route, and Next.js can verify that it exists and generate the correct tags. A static.jpgworks for fixed routes; a.tsxfile withImageResponseworks for dynamic routes . ImageResponseis a constrained rendering environment. It supports flexbox and absolute positioning but notdisplay: grid. Custom fonts must bettf,otf, orwoff, and the total bundle must stay under 500 KB. The generated image is cached at build time for static routes .- Memoize data shared between
generateMetadataand the page. React’scachefunction ensures the same query runs once. Without it, a database-backed dynamic route runs the query twice per request . - Set
metadataBasefor absolute OG image URLs. Social platforms require absolute URLs for images. WithoutmetadataBase, a relative path like/og.pngmay not resolve correctly in the rendered meta tag .
Remember: Metadata is how a page describes itself to search engines and social platforms. The App Router provides two ways to declare it: a static object for fixed values and an async function for values that depend on data. Social images follow the same pattern: a literal file for fixed routes and a generated ImageResponse for dynamic ones. The file convention takes precedence because it is colocated and verifiable. Use the static object when you can, generateMetadata when you must, and ImageResponse when every page needs its own card. Verify the output in the browser’s head inspector and in a social preview tool before shipping.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!