Dynamic OG Images in Next.js with opengraph-image.tsx
Generate dynamic Open Graph images in Next.js using the built-in opengraph-image.tsx convention. Satori, edge runtime, and caching strategies.
You paste a production link into Slack. The preview expands to a blank grey box containing your site's generic favicon. You have 340 blog posts, but exactly zero custom Open Graph images because generating them manually in Figma takes 10 minutes per post. You ship a generic fallback, and your click-through rate on social platforms flatlines at 0.8%.
When you refactor the component to fix it, you accidentally break the routing logic. Now 44/47 pages return a 500 error for their og:image tag. You don't notice until organic traffic drops 40% two weeks later.
Next.js solves the generation problem with opengraph-image.tsx. Indxel solves the validation problem. Here is how you ship dynamic Open Graph images that render in 45ms, cache at the edge, and never break in production.
What is opengraph-image.tsx in Next.js?
Next.js uses the opengraph-image.tsx file convention in the App Router to automatically generate dynamic Open Graph images at build time or request time. It replaces manual <meta property="og:image"> tags with a route that returns a dynamically rendered PNG.
When you place an opengraph-image.tsx file in a route segment, Next.js automatically injects the correct <meta> tags into your document <head>. You do not need to construct absolute URLs or handle the routing manually.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export const alt = 'Blog post cover image'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image({ params }: { params: { slug: string } }) {
const post = await fetchPost(params.slug)
return new ImageResponse(
(
<div style={{ display: 'flex', background: '#000', width: '100%', height: '100%' }}>
<h1 style={{ color: '#fff' }}>{post.title}</h1>
</div>
),
{ ...size }
)
}The output is a standard PNG file served at /blog/your-post-slug/opengraph-image. Next.js automatically appends ?abc=123 cache-busting hashes to the injected meta tags based on your deployment ID.
How do Satori and the Edge Runtime generate images?
Vercel's Satori engine converts HTML and CSS into an SVG, which @vercel/og then rasterizes into a PNG using Resvg. This runs entirely on the Edge Runtime, generating images in under 50 milliseconds without headless browsers.
Before Satori, developers used Puppeteer to spin up headless Chrome instances in Serverless Functions. That approach required 50MB+ binaries, took 3 to 5 seconds to cold-start, and frequently timed out on Vercel's hobby tier. Satori strips away the browser engine entirely.
Satori vs Puppeteer vs Static Assets
| Metric | Satori (@vercel/og) | Headless Chrome (Puppeteer) | Static PNGs |
|---|---|---|---|
| Cold Start Time | ~45ms | 3,000ms - 5,000ms | 0ms |
| Bundle Size | ~2MB | 50MB+ | N/A |
| CSS Support | Limited subset (Flexbox only) | Full CSS3 / Grid | N/A |
| Dynamic Data | Yes (API fetched) | Yes | No |
Satori wins definitively for Open Graph generation. The limited CSS support is a strict constraint, but you do not need CSS Grid or complex animations to render a 1200x630 static banner. You need speed, and Satori is 100x faster than Puppeteer.
How do you build a dynamic OG image for a blog post?
You build dynamic OG images by fetching your post data inside the opengraph-image.tsx file and mapping it to a JSX layout. Satori requires inline styles or Tailwind classes to calculate the layout.
Here is a production-ready example for a blog post. It fetches the title and author, applies a custom layout, and uses the edge runtime for speed.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPostBySlug } from '@/lib/db'
export const runtime = 'edge'
export const alt = 'Article cover'
export const size = { width: 1200, height: 630 }
export default async function Image({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug)
if (!post) {
return new Response('Not found', { status: 404 })
}
return new ImageResponse(
(
<div
style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'space-between',
backgroundColor: '#0a0a0a',
padding: '80px',
fontFamily: 'Inter',
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<p style={{ color: '#888', fontSize: 32, textTransform: 'uppercase' }}>
{post.category}
</p>
<h1
style={{
fontSize: 72,
fontWeight: 900,
color: '#ffffff',
lineHeight: 1.1,
letterSpacing: '-0.02em',
}}
>
{post.title}
</h1>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '24px' }}>
<img
src={post.author.avatarUrl}
width={80}
height={80}
style={{ borderRadius: '40px' }}
/>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ color: '#fff', fontSize: 32, fontWeight: 600 }}>
{post.author.name}
</span>
<span style={{ color: '#888', fontSize: 24 }}>
{post.author.role}
</span>
</div>
</div>
</div>
),
{
...size,
}
)
}Do not use next/image inside ImageResponse. Satori does not run in a browser and cannot process Next.js Image components. Use standard <img> tags with absolute URLs for all external assets.
How do you handle custom fonts in Satori?
Satori requires you to pass the raw ArrayBuffer of a .ttf or .otf font file directly into the ImageResponse configuration. You cannot use next/font or standard CSS @font-face declarations.
If you rely on system fonts, your images will render inconsistently. You must load fonts manually. Fetching them via fetch from your local public directory is the most reliable method when running on the Edge.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const runtime = 'edge'
export default async function Image() {
// Fetch the font from the public directory
const fontData = await fetch(
new URL('../../../public/fonts/Inter-Bold.ttf', import.meta.url)
).then((res) => res.arrayBuffer())
return new ImageResponse(
(
<div style={{ fontFamily: '"Inter"' }}>
Hello World
</div>
),
{
width: 1200,
height: 630,
fonts: [
{
name: 'Inter',
data: fontData,
style: 'normal',
weight: 700,
},
],
}
)
}If you need multiple font weights, you must fetch and pass multiple .ttf files into the fonts array. Loading 4 different font weights adds roughly 100-200ms to your image generation time due to file parsing. Keep it to one or two weights.
How do you generate OG images for product pages?
You generate product OG images by querying your catalog database and conditionally rendering pricing, stock status, and product imagery. This requires mapping dynamic booleans to visual badges.
Social platforms heavily penalize e-commerce links that lack imagery. A dynamic route at app/products/[id]/opengraph-image.tsx ensures every SKU has a valid card.
// app/products/[id]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getProduct } from '@/lib/catalog'
export const runtime = 'edge'
export const size = { width: 1200, height: 630 }
export default async function Image({ params }: { params: { id: string } }) {
const product = await getProduct(params.id)
return new ImageResponse(
(
<div style={{ display: 'flex', width: '100%', height: '100%', background: '#fff' }}>
{/* Left: Product Info */}
<div style={{ display: 'flex', flexDirection: 'column', width: '50%', padding: '60px' }}>
<h1 style={{ fontSize: 64, fontWeight: 'bold', color: '#111' }}>
{product.name}
</h1>
<p style={{ fontSize: 48, color: '#666', marginTop: 'auto' }}>
${product.price.toFixed(2)}
</p>
{/* Conditional Stock Badge */}
{product.inStock ? (
<div style={{ background: '#dcfce7', color: '#166534', padding: '12px 24px', borderRadius: '8px', width: 'fit-content', fontSize: 32, marginTop: '24px' }}>
In Stock
</div>
) : (
<div style={{ background: '#fee2e2', color: '#991b1b', padding: '12px 24px', borderRadius: '8px', width: 'fit-content', fontSize: 32, marginTop: '24px' }}>
Out of Stock
</div>
)}
</div>
{/* Right: Product Image */}
<div style={{ display: 'flex', width: '50%', alignItems: 'center', justifyContent: 'center', background: '#f8f9fa' }}>
<img src={product.imageUrl} width={500} height={500} style={{ objectFit: 'contain' }} />
</div>
</div>
),
{ ...size }
)
}How do caching and revalidation work for Next.js OG images?
Next.js caches opengraph-image.tsx responses identically to standard pages. You control the cache lifecycle using export const revalidate = 3600 or by relying on the default static rendering behavior.
If your route segment is static, Next.js generates the OG image at build time. If you use dynamic functions like cookies() or headers(), or if you specify dynamic fetching, the image generates on the first request and caches at the edge.
To invalidate the cache when a product price changes, use time-based revalidation:
// Revalidate this image every hour
export const revalidate = 3600;
export default async function Image() { ... }Alternatively, use on-demand revalidation by attaching cache tags to the fetch request inside the image route:
const product = await fetch(`https://api.example.com/products/${id}`, {
next: { tags: [`product-${id}`] }
}).then(res => res.json())When you call revalidateTag('product-123') in your CMS webhook handler, Next.js purges both the product page and its associated OG image from the CDN.
Social networks cache Open Graph images aggressively on their end. Twitter and LinkedIn might cache an image for up to 7 days. Purging your Next.js cache updates the image on your domain, but you must use the respective platform's card validator tools to force them to fetch the new image.
What is the real-world impact of dynamic OG images?
A SaaS blog implementing dynamic title-and-author images saw click-through rates from Twitter and LinkedIn increase from 1.2% to 4.5%. Generation takes 45ms at the edge, requiring 0 manual design time from the marketing team.
Static fallbacks fail because they provide no context. A user scrolling a timeline sees a generic logo instead of the specific article title. By mapping your H1 directly to the Open Graph image, you guarantee visual context on every shared link. For a site with 1,000 pages, opengraph-image.tsx eliminates hundreds of hours of manual asset creation.
How do you validate generated OG images?
You validate generated OG images by asserting that the route returns a 200 HTTP status, contains a valid PNG, and loads within acceptable time limits. If your edge function throws a 500 because a post title is null, you ship a broken image.
Standard SEO crawlers check if the <meta property="og:image" content="..."> tag exists in the HTML. They do not verify if the URL inside the content attribute actually resolves to an image. If your opengraph-image.tsx crashes, the meta tag still exists, but the URL points to a 500 error page.
Indxel catches this natively. The CLI runs locally or in CI, parses your meta tags, and executes a strict asset verification rule against the Open Graph endpoints.
npx indxel check --ciIf a component update breaks your OG image route, Indxel fails the build with exact file paths and rule IDs:
✖ 1 critical error found
/blog/nextjs-caching-guide
Error: og-image-resolution
The og:image URL (/blog/nextjs-caching-guide/opengraph-image) returned a 500 Internal Server Error.
Ensure your opengraph-image.tsx file handles null data correctly.You integrate this directly into your GitHub Actions workflow to guard your main branch:
name: SEO Infrastructure Guard
on: [pull_request]
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build Next.js app
run: npm run build
- name: Start server & Run Indxel
run: |
npm run start &
sleep 3
npx indxel check http://localhost:3000 --ciA typical Next.js app with 50 pages takes 3 seconds to validate with Indxel. That is 3 seconds in CI that prevents you from shipping broken social cards to production.
FAQ
Can I use standard HTML/CSS in Satori?
No, Satori only supports a strict subset of CSS, primarily relying on Flexbox. You cannot use CSS Grid, float, box-shadow, or advanced pseudo-selectors. All layouts must be constructed using flex containers, and display: flex is required on almost all block elements to behave predictably.
Why do my custom fonts fail in production?
Custom fonts fail in production because the Edge Runtime cannot access the local filesystem using fs.readFileSync. You must load fonts using fetch with a URL constructor pointing to import.meta.url, or host the font files on an external CDN and fetch them via absolute HTTPS URLs.
How do I generate multiple OG images for a single route?
You use the generateImageMetadata function alongside opengraph-image.tsx to return an array of image IDs. Next.js will execute your image component multiple times, passing the ID as a parameter. This is required when a product page has an image gallery and you want to expose all gallery images as Open Graph assets.
What is the difference between opengraph-image.tsx and twitter-image.tsx?
twitter-image.tsx generates an image specifically for the twitter:image meta tag, while opengraph-image.tsx targets og:image. If you only provide opengraph-image.tsx, Twitter will fall back to it automatically. You only need twitter-image.tsx if you want a specific aspect ratio or design exclusively for Twitter.
npx indxel check http://localhost:3000