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:
| Approach | Best For | Trade-offs |
|---|---|---|
| Static Images | Landing pages, fixed content | Manual updates required |
| Build-time Generation | Blogs, docs with known pages | Rebuild needed for changes |
| On-demand (Edge) | User content, dynamic data | Slight 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 OGSetting 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/ogCreating 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
imgtags withsrcas 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:
- Check the raw URL directly—visit
/api/og?title=Testto see the image - View page source and verify the
og:imagemeta tag has the correct URL - Use the og-image.org validator to check all platforms at once
- 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.