| | |

Next.js 14 ▲ Metadata and OG Images — Static/dynamic metadata & social cards

⏱️ Estimated read: 14 min | |

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

APILocationUse case
export const metadatalayout.tsx or page.tsxStatic values
export async function generateMetadatalayout.tsx or page.tsxValues from data
opengraph-image.jpgRoute segmentStatic OG image
opengraph-image.tsxRoute segmentGenerated OG image
twitter-image.jpg / .tsxRoute segmentTwitter card image

Key metadata fields

FieldPurpose
titlePage title (string or template object)
descriptionMeta description
openGraphOG title, description, images, type
twitterTwitter card type, title, images
metadataBaseBase URL for relative image paths

ImageResponse options

OptionDefaultPurpose
width1200Image width
height630Image height
fonts—Custom font array
emoji'twemoji'Emoji set

OG image constraints

ConstraintLimit
Max file size (OG)8 MB
Max file size (Twitter)5 MB
ImageResponse bundle500 KB
Supported fontsttf, otf, woff
CSS supportFlexbox, 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

PitfallWhy It HappensFix
OG image not showing in previewRelative URL without metadataBaseAdd metadataBase to the metadata
Generated image is blankCSS uses unsupported propertiesUse flexbox only; check Satori limitations
Metadata not updatingStatic metadata used for dynamic routeSwitch to generateMetadata
Duplicate data fetchNo cache() or fetch memoizationWrap the query in React’s cache
Image generation exceeds 500 KBLarge fonts or images in bundleReduce assets; use runtime fetching
Twitter card shows wrong imagetwitter-image missing and opengraph-image incompatibleAdd 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

ItemValue
Static metadataexport const metadata: Metadata
Dynamic metadataexport async function generateMetadata
Component restrictionServer Components only
Title templatetitle: { default, template }
Static OG imageopengraph-image.jpg
Generated OG imageopengraph-image.tsx + ImageResponse
Twitter imagetwitter-image.jpg / .tsx
ImageResponse defaults1200×630
ImageResponse CSSFlexbox only
Bundle limit500 KB

Key takeaways:

  • Static metadata is for values that do not change; generateMetadata is 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 parent argument to generateMetadata allows extending the parent’s values .
  • The opengraph-image file convention takes precedence over openGraph.images in 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 .jpg works for fixed routes; a .tsx file with ImageResponse works for dynamic routes .
  • ImageResponse is a constrained rendering environment. It supports flexbox and absolute positioning but not display: grid. Custom fonts must be ttf, otf, or woff, and the total bundle must stay under 500 KB. The generated image is cached at build time for static routes .
  • Memoize data shared between generateMetadata and the page. React’s cache function ensures the same query runs once. Without it, a database-backed dynamic route runs the query twice per request .
  • Set metadataBase for absolute OG image URLs. Social platforms require absolute URLs for images. Without metadataBase, a relative path like /og.png may 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.

Was this helpful?


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!