Dynamic Open Graph Images in Next.js With ImageResponse (next/og)
Every page on a site technically has an Open Graph image slot — most sites just leave it blank, or ship one generic banner image for the entire domain. Next.js's ImageResponse API (next/og) makes it cheap enough to generate a real, on-brand OG image per route instead, using JSX and inline styles, rendered to a PNG at request time (or at build time for static routes). No design tool, no manually exported PNGs sitting in /public.
The file convention
Next.js's App Router treats opengraph-image.tsx as a special file, the same way it treats layout.tsx or page.tsx. Drop one next to a route's page.tsx and Next.js automatically wires up the right <meta property="og:image"> tags — you don't touch the metadata export for it at all.
Here's a real one, the site-wide default for a marketing site:
import { ImageResponse } from 'next/og';
export const runtime = 'edge';
export const alt = 'DevFixel — Custom Software Development Studio';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
export default function OpengraphImage() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
padding: '80px',
background: 'linear-gradient(135deg, #0d6aa8 0%, #1280c7 55%, #3fa9dc 100%)',
fontFamily: 'sans-serif',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 16, fontSize: 40, fontWeight: 700, color: '#ffffff' }}>
DevFixel
</div>
<div style={{ display: 'flex', marginTop: 40, fontSize: 58, fontWeight: 700, color: '#ffffff', lineHeight: 1.15, maxWidth: 900 }}>
Custom Software That Keeps Pace With Your Business
</div>
</div>
),
{ ...size }
);
}
A few things worth noting about what's actually happening here:
-
It's just JSX, but not full CSS.
ImageResponseuses Satori under the hood, which supports a meaningful-but-limited subset of CSS — flexbox layout works well, grid does not, and most modern layout primitives you'd reach for in a browser aren't available. Stick to flex containers and you'll avoid most of the friction. -
runtime = 'edge'matters. Rendering happens at request time by default, so you want this on the edge runtime for low latency rather than a cold Node serverless function. -
The exported
sizeandaltaren't decorative — Next.js reads those exact exports to populateog:image:width,og:image:height, andog:image:altautomatically.
Making it dynamic per page
The example above is static — same image for every request, since nothing in it depends on route params. The actual payoff of this API shows up when the image reflects the specific page it's attached to: a blog post's title rendered onto the card, a product's name and price, a location page's city.
You do that the same way you'd make any other route segment dynamic — accept params:
// 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';
export default async function OpengraphImage({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug); // however you fetch it
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
padding: '80px',
background: '#0d6aa8',
}}
>
<div style={{ display: 'flex', fontSize: 56, fontWeight: 700, color: 'white', lineHeight: 1.2 }}>
{post.title}
</div>
</div>
),
{ ...size }
);
}
Next.js generates a distinct image per slug automatically — no extra routing config needed, because opengraph-image.tsx inherits the dynamic segment from its parent route the same way page.tsx does. If a post's title is long, that's genuinely a layout problem, not just an aesthetic one — you'll want a font-size clamp or truncation, since Satori doesn't give you graceful text overflow handling for free the way a browser's text-overflow: ellipsis does.
Caching and cost
For a route with a bounded, known set of params (a fixed list of locations, a small product catalog), you can pair this with generateStaticParams so the images get generated once at build time rather than on every request — worth doing if the underlying data doesn't change often, since edge-rendered images on every social-share request add up in both latency and function invocations for high-traffic pages.
Why this is worth doing at all
Social platforms use whatever og:image a page declares (or nothing, if it's absent) when a link gets shared — Slack, Twitter/X, LinkedIn, iMessage previews all read it. A generic site-wide image is fine as a fallback, but a blog post that shows its own title in the preview card gets noticeably more clicks than one showing the same banner as every other page on the domain. It's a small thing that's easy to skip, and cheap enough with next/og that there's not much reason to.
Using this pattern on DevFixel's marketing site for the site-wide default image, with per-route dynamic versions being the natural next step for the blog and location pages.
Top comments (0)