Skip to main content
Documentation

Next.js Open Graph Image Guide

Next.js has become the default choice for React developers who want performance and SEO out of the box. With the App Router (Next.js 13+), handling OG images is more powerful than ever. Let me walk you through every approach—from simple static images to fully dynamic, on-demand generation.

This guide covers Next.js 13+ with App Router. Using Pages Router? The concepts are similar, but the file structure and API routes differ slightly.

Understanding Your Options

When it comes to OG images in Next.js, you've got three main paths. Each has trade-offs, and the right choice depends on your specific needs:

ApproachBest ForTrade-offs
Static ImagesLanding pages, fixed contentManual updates required
Build-time GenerationBlogs, docs with known pagesRebuild needed for changes
On-demand (Edge)User content, dynamic dataSlight latency, compute cost

Method 1: Static OG Images

The simplest approach. Create your OG images using og-image.org, download them, and drop them in your public folder. This works perfectly for pages that don't change often—your homepage, about page, contact page.

File Structure

public/
├── og-home.png          # Homepage OG image
├── og-about.png         # About page OG image
└── og-contact.png       # Contact page OG image

app/
├── layout.tsx           # Root layout with default OG
├── page.tsx             # Homepage
├── about/
│   └── page.tsx         # About page with custom OG
└── contact/
    └── page.tsx         # Contact page with custom OG

Setting Up Metadata

In Next.js App Router, you export a metadata object from your page or layout. Here's how to set up OG images:

// app/layout.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  metadataBase: new URL('https://yoursite.com'),
  title: {
    default: 'Your Site Name',
    template: '%s | Your Site Name',
  },
  openGraph: {
    type: 'website',
    siteName: 'Your Site Name',
    images: [
      {
        url: '/og-home.png',
        width: 1200,
        height: 630,
        alt: 'Your Site Name - Description',
      },
    ],
  },
  twitter: {
    card: 'summary_large_image',
    images: ['/og-home.png'],
  },
}

The metadataBase is crucial—it turns your relative image paths into absolute URLs. Without it, social platforms can't find your images.

Page-Specific Overrides

// app/about/page.tsx
import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'About Us',
  description: 'Learn about our mission and team.',
  openGraph: {
    title: 'About Us',
    description: 'Learn about our mission and team.',
    images: [
      {
        url: '/og-about.png',
        width: 1200,
        height: 630,
        alt: 'About Your Site Name',
      },
    ],
  },
}

export default function AboutPage() {
  return <div>About page content</div>
}

Method 2: Build-Time Generation with generateStaticParams

For blogs and documentation sites with dynamic routes, you can generate OG images at build time. This gives you the performance of static images with the convenience of dynamic content.

The idea: create your OG images using og-image.org based on your content titles, then reference them in your metadata. If you have 100 blog posts, create 100 OG images during your build process or manually before deployment.

Dynamic Metadata Generation

// app/blog/[slug]/page.tsx
import type { Metadata } from 'next'
import { getPostBySlug, getAllPosts } from '@/lib/posts'

type Props = {
  params: { slug: string }
}

export async function generateStaticParams() {
  const posts = await getAllPosts()
  return posts.map((post) => ({ slug: post.slug }))
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getPostBySlug(params.slug)

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: 'article',
      publishedTime: post.date,
      authors: [post.author],
      images: [
        {
          url: `/og/blog/${params.slug}.png`,
          width: 1200,
          height: 630,
          alt: post.title,
        },
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.excerpt,
      images: [`/og/blog/${params.slug}.png`],
    },
  }
}

export default async function BlogPost({ params }: Props) {
  const post = await getPostBySlug(params.slug)
  return <article>{/* post content */}</article>
}

Method 3: On-Demand Generation with @vercel/og

Here's where things get interesting. Vercel's @vercel/og library (which uses Satori under the hood—the same technology we use at og-image.org) lets you generate images on-the-fly at the Edge.

This is perfect for user-generated content, real-time data, or when you have thousands of pages and can't pre-generate everything.

Installation

npm install @vercel/og

Creating an OG Image Route

// app/api/og/route.tsx
import { ImageResponse } from '@vercel/og'
import { NextRequest } from 'next/server'

export const runtime = 'edge'

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)

  const title = searchParams.get('title') || 'Default Title'
  const description = searchParams.get('description') || ''

  return new ImageResponse(
    (
      <div
        style={{
          height: '100%',
          width: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          backgroundColor: '#0a0a0a',
          padding: 80,
        }}
      >
        <div
          style={{
            display: 'flex',
            flexDirection: 'column',
            alignItems: 'center',
            justifyContent: 'center',
            textAlign: 'center',
          }}
        >
          <h1
            style={{
              fontSize: 64,
              fontWeight: 700,
              color: 'white',
              marginBottom: 24,
              lineHeight: 1.2,
            }}
          >
            {title}
          </h1>
          {description && (
            <p
              style={{
                fontSize: 32,
                color: '#a3a3a3',
                maxWidth: 800,
              }}
            >
              {description}
            </p>
          )}
        </div>
      </div>
    ),
    {
      width: 1200,
      height: 630,
    }
  )
}

Using the Dynamic Image

// app/blog/[slug]/page.tsx
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const post = await getPostBySlug(params.slug)

  // Encode parameters for URL
  const ogUrl = new URL('/api/og', 'https://yoursite.com')
  ogUrl.searchParams.set('title', post.title)
  ogUrl.searchParams.set('description', post.excerpt)

  return {
    title: post.title,
    openGraph: {
      images: [
        {
          url: ogUrl.toString(),
          width: 1200,
          height: 630,
        },
      ],
    },
  }
}

Next.js 14+ opengraph-image Convention

Next.js 14 introduced a brilliant convention: just create an opengraph-image.tsx file in any route segment, and Next.js automatically generates and serves the OG image. No API routes needed.

// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPostBySlug } from '@/lib/posts'

export const runtime = 'edge'
export const alt = 'Blog Post'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'

export default async function Image({ params }: { params: { slug: string } }) {
  const post = await getPostBySlug(params.slug)

  return new ImageResponse(
    (
      <div
        style={{
          fontSize: 48,
          background: 'linear-gradient(to bottom right, #1a1a2e, #16213e)',
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          alignItems: 'center',
          justifyContent: 'center',
          color: 'white',
          padding: 48,
        }}
      >
        <h1 style={{ textAlign: 'center', maxWidth: 900 }}>{post.title}</h1>
      </div>
    ),
    { ...size }
  )
}

Next.js automatically adds the OG image meta tags. You don't even need to specify them in your metadata export. Magic.

Using Custom Fonts

Default system fonts are boring. Here's how to use custom fonts in your OG images:

// app/api/og/route.tsx
import { ImageResponse } from '@vercel/og'

export const runtime = 'edge'

// Load font at the edge
const interBold = fetch(
  new URL('./fonts/Inter-Bold.ttf', import.meta.url)
).then((res) => res.arrayBuffer())

export async function GET(request: Request) {
  const fontData = await interBold

  return new ImageResponse(
    (
      <div
        style={{
          fontFamily: 'Inter',
          // ... rest of your styles
        }}
      >
        Your Title Here
      </div>
    ),
    {
      width: 1200,
      height: 630,
      fonts: [
        {
          name: 'Inter',
          data: fontData,
          style: 'normal',
          weight: 700,
        },
      ],
    }
  )
}

Performance Optimization

OG image generation adds latency. Here's how to minimize it:

1. Use Edge Runtime

Always add export const runtime = 'edge' to your OG image routes. Edge functions start faster and run closer to users.

2. Cache Aggressively

Set proper cache headers. OG images don't change often, so cache them for hours or days.

return new ImageResponse(jsx, {
  headers: {
    'Cache-Control': 'public, max-age=86400, s-maxage=86400',
  },
})

3. Keep It Simple

Complex layouts take longer to render. Stick to basic flexbox layouts and avoid heavy gradients or too many elements.

Common Gotchas

  • Missing metadataBase: Without it, relative URLs won't work. Always set it in your root layout.
  • CSS Limitations: Satori (the rendering engine) doesn't support all CSS. No CSS Grid, limited flexbox, no pseudo-elements. Keep styles simple.
  • Font Loading: Fonts must be loaded as ArrayBuffer. Can't use Google Fonts directly—download the .ttf files.
  • Image Loading in JSX: External images work, but you need to use img tags with src as the full URL.
  • Cache Invalidation: Social platforms cache OG images aggressively. Add cache-busting query params when testing.

Testing Your Implementation

Before deploying, verify your OG images work:

  1. Check the raw URL directly—visit /api/og?title=Test to see the image
  2. View page source and verify the og:image meta tag has the correct URL
  3. Use the og-image.org validator to check all platforms at once
  4. Test the actual share preview using platform debuggers

When to Use og-image.org vs @vercel/og

Both tools use the same underlying technology (Satori). Here's when to use each:

Use og-image.org when:

  • • Creating static images manually
  • • Designing templates visually
  • • Non-technical users need to create images
  • • You want zero-config, instant results

Use @vercel/og when:

  • • Generating images dynamically at runtime
  • • Images depend on real-time data
  • • You have thousands of pages
  • • Full programmatic control is needed

Next Steps

You've got the knowledge. Now implement it. Start with static images using og-image.org, then add dynamic generation as needed.