Dynamic Open Graph Images with Next.js ImageResponse

Photo by mkhmarketing on flickr
ImageResponse is a constructor exported from next slash og that turns JSX and CSS into a rendered PNG at request time or build time. It is most commonly used to generate Open Graph and Twitter card images so every page or blog post gets a unique, on-brand social share image instead of one static banner reused everywhere.
ImageResponse only supports ttf, otf, and woff font formats, and it will not automatically pick up a font just because it is loaded elsewhere in your app. You have to read the font file yourself and pass it into the fonts option as raw binary data. A common mistake is loading the font inside the component function on every request instead of once at module scope, which works but wastes time on every invocation.
Create an opengraph-image.tsx file inside the dynamic route segment, for example inside a slug folder, and read the slug from the params argument the function receives. Use that slug to look up the post's title and category, then render them into the ImageResponse JSX. Next.js automatically wires the correct meta tags to the page head, so you do not need to add anything manually.
Only if the route runs on the Node.js runtime, which is the default. If you switch the route to the Edge runtime, node colon fs is not available and any filesystem read will fail. In that case, fetch fonts and other assets over HTTP from a public URL instead, and cache the fetched bytes so they are not re-downloaded on every request.
Most social platforms cache the first version of a link's preview aggressively and will keep showing the old image even after you fix your route handler. Confirm the route itself returns a fresh image with a direct HTTP request first, then use the platform's own debugging tool, such as Facebook's Sharing Debugger or LinkedIn's Post Inspector, to force a re-scrape rather than relying on a normal page refresh.

Photo by mkhmarketing on flickr
Key Takeaway
Next.js can generate a unique Open Graph image for every blog post at request time using the ImageResponse constructor, which converts JSX and CSS into a PNG via Satori and resvg. Loading custom fonts once at module scope, choosing the right caching strategy, and verifying output with a platform debugging tool are what make the approach reliable in production.
Every link preview you have ever seen on Slack, iMessage, LinkedIn, or X is powered by a handful of meta tags in the head of the page, and the most important one is the image. For years the easiest way to satisfy that tag was to export a single static banner from a design tool and reuse it everywhere. That works fine for a homepage. It falls apart the moment you have fifty blog posts, each with a different title, category, and reading time, and you want the shared link to actually communicate what the article is about before anyone clicks it.
Next.js solves this with the ImageResponse constructor, exposed from the next slash og module. It lets you describe an image as plain JSX and CSS, the same mental model you already use for components, and get back a rendered PNG on the fly. Pair it with the opengraph-image file convention and you get a unique, on-brand share image for every route, generated from real data, with almost no extra infrastructure. This post walks through the mechanics, the font and caching gotchas that catch people out, and how to actually verify the result before you ship it.
Next.js treats a file named opengraph-image inside any route segment as a specialized route handler. Instead of exporting a GET function, you export a default async function that returns an ImageResponse, a Blob, or a plain Response. Next.js automatically wires up the resulting og colon image meta tag, including width, height, and content type, so you never hand-write those tags yourself. The example below reads the dynamic slug parameter, looks up the matching post, and renders its title and category directly into the generated image.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPostBySlug } from '@/lib/posts'
export const alt = 'Blog post cover'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const post = await getPostBySlug(slug)
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '80px',
backgroundColor: '#0b0f19',
color: 'white',
}}
>
<div style={{ fontSize: 56, fontWeight: 700 }}>{post.title}</div>
<div style={{ fontSize: 28, color: '#94a3b8', marginTop: 24 }}>
{post.category} · {post.readTimeMinutes} min read
</div>
</div>
),
{ ...size }
)
}Under the hood, ImageResponse chains together three separate open source projects: Satori converts your JSX and CSS into an SVG, then a Rust based renderer called resvg rasterizes that SVG into a PNG, and the whole pipeline is packaged as the at vercel slash og library that Next.js re-exports. Because Satori has to implement CSS layout itself rather than delegating to a real browser engine, only a subset of CSS is supported.
Prototype your layout in the Vercel OG Playground before wiring it into your app. It runs the exact same Satori engine in the browser, so what you see there is what you will get from your route handler, and iterating there is far faster than redeploying to check a single pixel offset.
ImageResponse ships with a default sans serif font, but it will not know about the display font your brand actually uses unless you load it yourself. Only ttf, otf, and woff formats are supported, and the documentation specifically recommends ttf or otf over woff because they parse faster inside the rendering pipeline. The critical detail is where you load the font file. If you call a filesystem read or a fetch inside the component function itself, you re-read the same bytes on every single request. Load it once at module scope instead, so the bytes are cached in memory for the lifetime of the server instance and reused across warm invocations.
// Loading a custom font once, at module scope,
// so it survives across warm invocations
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
const fontData = await readFile(
join(process.cwd(), 'assets/PlusJakartaSans-Bold.ttf')
)
// Pass it into ImageResponse options
new ImageResponse(element, {
width: 1200,
height: 630,
fonts: [
{
name: 'Plus Jakarta Sans',
data: fontData,
style: 'normal',
weight: 700,
},
],
})If your opengraph-image route runs on the Edge runtime, node colon fs is not available, so you cannot read a font file straight off disk. Fetch the font over HTTP from a CDN or your own public folder URL instead, and cache the response the same way you would cache it in a Node runtime, otherwise every cold start pays the full download cost again.
A generated opengraph-image is statically optimized and produced once at build time by default, exactly like a page that has no dynamic data dependency. It only switches to request time generation automatically when it touches a request time API, such as reading cookies or headers, or when it fetches uncached data. This distinction matters a lot for cost and latency: a static image is built once and served from cache forever, while a request time image is rendered fresh on every single social crawler hit unless you add your own caching layer on top.
| Generation mode | When it triggers | Best for |
|---|---|---|
| Static (build time) | No cookies, headers, or uncached fetch calls inside the function | Fixed pages like the homepage or an about page |
| Request time, cached | Fetches data with the default fetch cache behavior | Blog posts and product pages with a slug parameter |
| Request time, uncached | Reads cookies, headers, or explicitly opts out of fetch caching | Personalized cards, such as a referral image with a username |
For a blog, the sweet spot is almost always the middle row of that table: let the data fetch inside opengraph-image use the framework default caching so the rendered PNG is effectively generated once per post and then reused, while still allowing a full rebuild to refresh it whenever content changes. Three habits keep this reliable in production.
Because the image is derived straight from the same title, category, and read time metadata your page already renders, it can never drift out of sync with the actual article the way a hand made banner eventually will after the tenth content edit.
The generated image is just a route response, so the fastest sanity check is a plain HTTP request against the opengraph-image URL for a given post, confirming it returns a 200 status and an image content type. That only proves the pixels exist though, not that the tags in the page head are correct or that the major platforms will actually display them. Each big platform caches the first version of a link aggressively, so after fixing anything you need to force a re-scrape using that platform's own debugging tool rather than just refreshing the page in your browser.
# Quick manual OG debugging checklist
curl -sI https://yoursite.com/blog/my-post/opengraph-image | grep -i content-type
# Should return: content-type: image/png
# Force a re-scrape after you fix something
# Facebook: https://developers.facebook.com/tools/debug/
# X/Twitter: https://cards-dev.twitter.com/validator (legacy, may 404)
# LinkedIn: https://www.linkedin.com/post-inspector/