Next.js Dynamic OG Images with ImageResponse
next/og generates per-page OG images at build time. Here's the pattern that produces social-share cards that don't look generic.
A generic OG image is a missed opportunity. When someone shares your blog post on Slack or Twitter, the preview card is the first thing the reader sees — before they decide whether to click. A card showing the actual post title, author name, and publication date performs better than a static logo on a white background.
next/og makes per-page OG images practical. You write a JSX component, Next.js renders it to a PNG using Satori, and the image is available at a predictable URL. Here's the production pattern.
The File Convention
The App Router uses a file convention for OG images: create opengraph-image.tsx in the same directory as your page.tsx. Next.js automatically serves it at /<route>/opengraph-image and wires up the og:image meta tag.
app/
blog/
[slug]/
page.tsx
opengraph-image.tsx ← OG image for /blog/[slug]
opengraph-image.tsx ← OG image for / (homepage)
You don't need to manually add og:image to your metadata — Next.js handles that automatically when it finds the opengraph-image.tsx file.
Basic ImageResponse
The file exports a default function that returns an ImageResponse:
// app/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export const alt = 'RankCrab — On-Page SEO for Developers'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default function OGImage() {
return new ImageResponse(
(
<div
style={{
background: '#0f172a',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'flex-end',
padding: '80px',
}}
>
<div style={{ color: '#38bdf8', fontSize: 24, marginBottom: 16 }}>
rankcrab.com
</div>
<div
style={{
color: '#f8fafc',
fontSize: 64,
fontWeight: 700,
lineHeight: 1.1,
}}
>
On-Page SEO for Developers
</div>
</div>
),
{ ...size }
)
}
Dynamic Images from Route Params
The real value is per-page images. For a dynamic route like /blog/[slug], the opengraph-image.tsx file receives the route params and can fetch data:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
type Props = {
params: { slug: string }
}
export default async function OGImage({ params }: Props) {
const post = await fetch(
`${process.env.API_URL}/posts/${params.slug}`
).then((r) => r.json())
return new ImageResponse(
(
<div
style={{
background: '#0f172a',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '80px',
}}
>
<div style={{ color: '#38bdf8', fontSize: 24 }}>RankCrab Blog</div>
<div>
<div
style={{
color: '#f8fafc',
fontSize: 56,
fontWeight: 700,
lineHeight: 1.2,
marginBottom: 24,
}}
>
{post.title}
</div>
<div style={{ color: '#94a3b8', fontSize: 24 }}>
{post.author} · {new Date(post.publishedAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
</div>
</div>
</div>
),
{ ...size }
)
}
Loading Custom Fonts
Satori only supports fonts you provide explicitly — system fonts don't transfer. Load fonts from your public folder or from a CDN:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
async function loadFont() {
const response = await fetch(
new URL('/fonts/Inter-Bold.ttf', process.env.NEXT_PUBLIC_SITE_URL!)
)
return response.arrayBuffer()
}
type Props = {
params: { slug: string }
}
export default async function OGImage({ params }: Props) {
const [post, fontData] = await Promise.all([
fetch(`${process.env.API_URL}/posts/${params.slug}`).then((r) => r.json()),
loadFont(),
])
return new ImageResponse(
(
<div
style={{
fontFamily: 'Inter',
background: '#0f172a',
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
padding: '80px',
}}
>
<div
style={{
color: '#f8fafc',
fontSize: 60,
fontWeight: 700,
lineHeight: 1.1,
}}
>
{post.title}
</div>
</div>
),
{
...size,
fonts: [
{
name: 'Inter',
data: fontData,
style: 'normal',
weight: 700,
},
],
}
)
}
Static vs. Runtime Generation
By default, opengraph-image.tsx files generate at build time for static routes. For dynamic routes with generateStaticParams, images are generated for each pre-rendered page at build time. For dynamic routes without generateStaticParams, images are generated on first request and then cached.
The export const runtime = 'edge' directive runs generation on the Edge Runtime. This is faster than Node.js for on-demand generation (lower cold start, geographically distributed). Use it unless you need Node.js-specific APIs.
For pages where the post content changes frequently, use export const revalidate = 3600 to revalidate the image every hour alongside the page itself.
export const runtime = 'edge'
export const revalidate = 3600 // revalidate every hour
The JSX Subset Satori Supports
Satori renders a JSX subset — not full HTML/CSS. The constraints that trip people up:
- Only flexbox layout is supported. No CSS Grid.
display: flexmust be set explicitly on container elements — it's not the default.- Images must use absolute URLs. Relative paths don't resolve in the Edge Runtime.
background-imagewithlinear-gradientworks. Complexbackgroundshorthand may not.- Use
styleobjects, not Tailwind classes (unless you use thetwprop from a Satori Tailwind plugin).
For background images (e.g., a subtle texture), fetch them as ArrayBuffer and convert to a data URL:
const bgImage = await fetch('https://yourdomain.com/og-bg.png')
.then((r) => r.arrayBuffer())
.then((buf) => `data:image/png;base64,${Buffer.from(buf).toString('base64')}`)
Verifying OG Images
After deploying, verify your OG images look correct across platforms. The meta tag previewer shows the rendered preview card as it would appear on Twitter/X. Things to check:
- Image resolves to an absolute URL (check the
og:imagemeta tag value) - Image dimensions are 1200×630
- Text is not clipped or overflowing
- File size is under 5MB (Twitter's limit)
Common Issues
"ReferenceError: Buffer is not defined" — the Edge Runtime doesn't have Node's Buffer. Use btoa(String.fromCharCode(...new Uint8Array(buf))) for base64 encoding, or switch to the Node.js runtime with export const runtime = 'nodejs'.
Font not loading in production — font fetch URLs must be absolute. Use process.env.NEXT_PUBLIC_SITE_URL to construct the full URL, and make sure the font file is in your public directory and included in your deployment.
Image not appearing in social preview — social platforms cache OG images aggressively. Use the Open Graph debugger and Twitter's Card Validator to force a cache refresh.
This article is part of the SEO for Next.js cluster. Related reading: