Dynamic OG Image Generation in Next.js
Generate dynamic Open Graph images in Next.js using @vercel/og and the ImageResponse API for social media previews with custom fonts and layouts.
What you'll learn
- ✓How Open Graph images work and why dynamic generation matters
- ✓Using the ImageResponse API from next/og
- ✓Designing OG images with JSX and inline styles
- ✓Loading custom fonts and external images
- ✓Generating images for blog posts, products, and user profiles
Prerequisites
- •Working knowledge of [Next.js App Router](/blog/nextjs-app-router-basics)
- •Familiarity with [Next.js route handlers](/blog/nextjs-route-handlers-vs-api-routes)
When you share a link on Twitter, LinkedIn, or Slack, the platform fetches an Open Graph image to display as a preview card. Manually creating these images for every page is tedious. Next.js lets you generate them dynamically at request time using JSX, so every page gets a unique, branded preview card automatically.
How it works
Next.js includes the ImageResponse class (from next/og) that converts JSX into a PNG image. It runs on the Edge Runtime using Satori under the hood, which converts HTML and CSS subset into SVG, then renders it as a PNG.
The generated image is served from a route handler or a special opengraph-image.tsx file that Next.js recognizes automatically.
The opengraph-image.tsx convention
The simplest approach uses the file convention. Create an opengraph-image.tsx file in any route segment:
// app/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export const alt = 'My Site';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default function OGImage() {
return new ImageResponse(
(
<div
style={{
fontSize: 64,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'white',
fontFamily: 'sans-serif',
}}
>
Welcome to My Site
</div>
),
{ ...size }
);
}
Next.js automatically sets the <meta property="og:image"> tag in the page’s metadata. No manual metadata configuration needed.
Dynamic OG images for blog posts
Generate unique images for each blog post using route params:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export const alt = 'Blog post preview';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default async function OGImage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// Fetch the post data
const post = await fetch(
`https://api.example.com/posts/${slug}`
).then((r) => r.json());
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
background: '#0f172a',
padding: '60px',
fontFamily: 'sans-serif',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
marginBottom: '40px',
}}
>
<div
style={{
fontSize: 24,
color: '#94a3b8',
background: '#1e293b',
padding: '8px 16px',
borderRadius: '8px',
}}
>
{post.category}
</div>
</div>
<div
style={{
fontSize: 56,
fontWeight: 700,
color: '#f8fafc',
lineHeight: 1.2,
flex: 1,
display: 'flex',
alignItems: 'center',
}}
>
{post.title}
</div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: 24,
color: '#94a3b8',
}}
>
<span>{post.author}</span>
<span>{post.readTime} min read</span>
</div>
</div>
),
{ ...size }
);
}
Loading custom fonts
Custom fonts make OG images match your brand. Load font files from the filesystem or a URL:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export default async function OGImage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// Load custom font
const interBold = await fetch(
new URL('../../fonts/Inter-Bold.ttf', import.meta.url)
).then((res) => res.arrayBuffer());
const interRegular = await fetch(
new URL('../../fonts/Inter-Regular.ttf', import.meta.url)
).then((res) => res.arrayBuffer());
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
background: 'white',
padding: '60px',
}}
>
<h1
style={{
fontSize: 64,
fontFamily: 'Inter Bold',
color: '#111827',
}}
>
Article Title Here
</h1>
<p
style={{
fontSize: 28,
fontFamily: 'Inter Regular',
color: '#6b7280',
}}
>
A brief description of the article.
</p>
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter Bold',
data: interBold,
style: 'normal',
weight: 700,
},
{
name: 'Inter Regular',
data: interRegular,
style: 'normal',
weight: 400,
},
],
}
);
}
Place font files in a fonts/ directory at the project root or inside app/.
Using external images
Include images like logos or avatars in your OG images:
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="https://example.com/logo.png"
alt=""
width={48}
height={48}
style={{ borderRadius: '50%' }}
/>
<span style={{ fontSize: 28, color: '#374151' }}>Example.com</span>
</div>
For images hosted on your own domain, use an absolute URL:
const baseUrl = process.env.VERCEL_URL
? `https://${process.env.VERCEL_URL}`
: 'http://localhost:3000';
<img src={`${baseUrl}/logo.png`} alt="" width={64} height={64} />
Using a route handler instead
If you need more control or want to generate images for paths that do not map to the file convention:
// app/api/og/route.tsx
import { ImageResponse } from 'next/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 category = searchParams.get('category') ?? 'General';
return new ImageResponse(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
background: '#1a1a2e',
padding: '60px',
color: 'white',
fontFamily: 'sans-serif',
}}
>
<div style={{ fontSize: 20, color: '#e94560', marginBottom: '20px' }}>
{category}
</div>
<div style={{ fontSize: 56, fontWeight: 700, lineHeight: 1.3 }}>
{title}
</div>
</div>
),
{ width: 1200, height: 630 }
);
}
Reference it in your page metadata:
// app/blog/[slug]/page.tsx
import type { Metadata } from 'next';
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const post = await getPost(slug);
return {
title: post.title,
openGraph: {
title: post.title,
description: post.description,
images: [
{
url: `/api/og?title=${encodeURIComponent(post.title)}&category=${encodeURIComponent(post.category)}`,
width: 1200,
height: 630,
},
],
},
};
}
CSS limitations
Satori supports a subset of CSS. Key limitations to be aware of:
- Flexbox only. No CSS Grid, no floats, no
position: absolute(use nested flex containers) - No pseudo-elements. No
::before,::after - No CSS variables. Use inline style values directly
- Limited text styling.
text-decoration,text-transform, andletter-spacingwork.text-shadowdoes not - All elements need
display: flex. Every<div>must explicitly setdisplay: 'flex'
Always test your OG images in a browser during development. Visit the route directly (e.g., /api/og?title=Test) to see the rendered PNG.
Caching OG images
OG images are generated on every request by default. Add caching headers for images that do not change frequently:
export async function GET(request: NextRequest) {
// ... generate the image
const response = new ImageResponse(/* ... */);
response.headers.set(
'Cache-Control',
'public, max-age=86400, s-maxage=86400, stale-while-revalidate=604800'
);
return response;
}
For the file convention, use the revalidate export:
export const revalidate = 86400; // Cache for 24 hours
Testing OG images
Verify your images look correct on social platforms using these tools:
- Twitter Card Validator: cards-dev.twitter.com/validator
- Facebook Sharing Debugger: developers.facebook.com/tools/debug
- LinkedIn Post Inspector: linkedin.com/post-inspector
- opengraph.xyz: previews how your link appears across platforms
Dynamic OG images give every page on your site a polished, branded social media presence. The file convention approach is the easiest to set up, while route handlers offer more flexibility for complex scenarios.
Related articles
- Next.js Next.js SEO: Meta Tags, Sitemap, and Structured Data
Optimize your Next.js app for search engines with the Metadata API, dynamic Open Graph images, sitemaps, robots.txt, and JSON-LD structured data.
- Next.js Deploying Next.js on Vercel: A Practical Guide
A hands-on walkthrough for shipping a Next.js app to Vercel — connecting Git, configuring environment variables, understanding preview deployments, and avoiding the usual production gotchas.
- Next.js Next.js Middleware and the Edge Runtime
What Next.js middleware actually is, how it runs on the Edge, what you can and cannot do there, and the patterns that work in production.
- Next.js Next.js Metadata API for SEO
Master the Next.js Metadata API with static and dynamic titles, OpenGraph tags, sitemaps, and robots.txt to ship pages that rank and share well.