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.
The file convention: opengraph-image.tsx
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 }
)
}How ImageResponse actually renders pixels
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.
- Layout is flexbox only. There is no grid support, so build your composition with nested divs, flex direction, and gap the way you would in an old email template.
- Text wrapping, absolute positioning, and background gradients all work, which covers the vast majority of real OG card designs.
- Images can be embedded as a data URI or as a raw array buffer passed to the src attribute, useful for logos or author avatars baked in at build time.
- The whole bundle, meaning your JSX, fonts, and any embedded images combined, is capped at 500 kilobytes, so keep font subsets and logo assets lean.
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.
Custom fonts: the part everyone gets wrong first
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.
Static generation versus request time generation
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 |
A practical caching strategy
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.
- Set explicit alt, size, and content type exports on every opengraph-image file. They become the accessible alt text and the width and height meta tags automatically, so crawlers do not have to guess the aspect ratio.
- Keep the JSX simple enough to render in well under a second. Slow image generation is invisible to your visitors but very visible to social crawlers, several of which apply a short timeout before falling back to no image at all.
- If you use generateImageMetadata to emit multiple variants, for example a square image for one platform and a wide one for another, give each an explicit id so cache keys stay stable across deploys.
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.
Verifying the result before you ship it
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/
