
Next.js ImageResponse
Every time a link to your site gets dropped into Slack, iMessage, or Twitter, something has to decide what the preview card looks like. For most sites that "something" is a static image sitting in /public, the same for every single page. ImageResponse exists so that image doesn't have to be static — it lets you generate a PNG on the fly, from JSX and CSS, using the actual title, author, or category of the page being shared. A blog with 500 posts can have 500 distinct, on-brand social cards without a designer ever opening Figma 500 times.
It sounds like it should require a headless browser and a screenshot library, and historically that's exactly what it took — spin up Puppeteer, load a page, screenshot it, hope the serverless function doesn't time out. ImageResponse skips all of that. It doesn't render a browser page and photograph it; it takes a React element tree, lays it out with a constrained CSS engine, and rasterizes the result directly to PNG. No browser, no screenshot, no headless Chromium binary bloating your deployment.
What ImageResponse Actually Is
ImageResponse is a constructor you import from next/og. You give it two things: a JSX element describing what the image should look like, and an options object describing image dimensions, fonts, and a few HTTP response details. It returns a Response object — specifically, one with an image body — which means you can return it directly from a Route Handler or from a special metadata file like opengraph-image.tsx.
import { ImageResponse } from 'next/og'
new ImageResponse(
element: ReactElement,
options: {
width?: number = 1200
height?: number = 630
emoji?: 'twemoji' | 'blobmoji' | 'noto' | 'openmoji' = 'twemoji',
fonts?: {
name: string,
data: ArrayBuffer,
weight: number,
style: 'normal' | 'italic'
}[]
debug?: boolean = false
// Options that will be passed to the HTTP response
status?: number = 200
statusText?: string
headers?: Record<string, string>
},
)
Under the hood, three libraries do the actual work: Satori converts your JSX and CSS into SVG, Resvg rasterizes that SVG into a PNG, and @vercel/og wraps the whole pipeline into the single ImageResponse API you actually import. You never call any of those three directly — Next.js hides the plumbing — but knowing they're there explains a lot of the constraints you'll run into, because Satori doesn't implement the full CSS spec. It implements the parts of CSS that make sense for a fixed-size layout: flexbox, absolute positioning, borders, gradients, text wrapping. It does not implement display: grid, CSS animations, or most of the layout modes you'd reach for on a normal webpage. If you've ever built with React Native's StyleSheet or written Yoga-based layouts, the mental model will feel familiar — you're laying out boxes with flexbox, not building a webpage.
The Options Object, Field by Field
width / height — default to 1200 × 630, which is not an arbitrary number. It's the de facto standard Open Graph image size that Facebook, LinkedIn, and most other platforms expect, and using it means your image won't get cropped or letterboxed in a feed. If you're generating a Twitter-only card, some teams use 1200 × 600 instead, but 1200 × 630 is safe everywhere and is what Next.js defaults to for a reason.
fonts — an array of font definitions, each needing raw font data as an ArrayBuffer, not a URL or a file path. This trips people up constantly: you can't just point fonts at a .ttf file the way you would a CSS @font-face rule. You have to read the file's bytes yourself, usually with Node's fs/promises, and hand over the buffer:
import { readFile } from "node:fs/promises";
import { join } from "node:path";
const interSemiBold = await readFile(
join(process.cwd(), "assets/Inter-SemiBold.ttf"),
);
Only ttf, otf, and woff are supported — notably not woff2, which is what most modern font CDNs serve by default. If you download a Google Font, double-check you grabbed a .ttf and not a .woff2, or the font will silently fail to load and you'll get a generic system font in the output with no error telling you why.
emoji — controls which emoji rendering set gets used when your JSX contains emoji characters, because emoji aren't just Unicode code points to a rasterizer, they're actual glyphs that have to come from somewhere. twemoji (Twitter's open-source set) is the default and is a safe, widely-recognized style; noto, blobmoji, and openmoji give you different visual styles if twemoji doesn't match your brand.
debug — set this to true during development and Satori draws a colored outline around every box in your layout, the same way browser DevTools' layout inspector does. Layout bugs in ImageResponse are genuinely harder to debug than in a browser because you can't just open DevTools on the output — it's a PNG, not a live DOM — so debug: true is the closest thing you get to that inspection, and it's worth turning on the moment your layout does something unexpected.
status / statusText / headers — these pass straight through to the underlying Response. You'd use status: 404 if you're generating a "this content wasn't found" placeholder image, for instance, or set custom headers to control caching behavior on the generated image at the CDN layer.
Where You Actually Use It
There are two distinct places ImageResponse shows up, and they solve different problems.
Inside a Route Handler
This is the general-purpose case: any time you want an endpoint that returns a generated image, for any reason, not necessarily tied to page metadata.
// app/api/route.js
import { ImageResponse } from "next/og";
export async function GET() {
try {
return new ImageResponse(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundColor: "white",
padding: "40px",
}}
>
<div style={{ fontSize: 60, fontWeight: "bold", color: "black" }}>
Welcome to My Site
</div>
</div>,
{ width: 1200, height: 630 },
);
} catch (e) {
console.log(`${e.message}`);
return new Response("Failed to generate the image", { status: 500 });
}
}
Note the try/catch. Satori throws real, uncaught errors when it hits CSS it doesn't understand or a font it can't parse, and if you don't catch that, your Route Handler returns a raw 500 with a stack trace instead of a graceful failure. Wrapping every ImageResponse in a try/catch that falls back to a plain error response is not optional defensive coding here — it's close to mandatory, because the failure modes (unsupported CSS property, malformed font buffer, oversized bundle) are common enough that you will hit one eventually.
Inside a file-based metadata convention
The other place ImageResponse shows up is inside opengraph-image.tsx (or twitter-image.tsx), the special file convention Next.js reads automatically to generate the Open Graph/Twitter Card image for a route. Rather than manually adding <meta property="og:image"> tags, you export a default function that returns an ImageResponse, and Next.js wires it into the page's metadata for you.
// app/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const alt = "My site";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image() {
return new ImageResponse(
<div
style={{
fontSize: 128,
background: "white",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
My site
</div>,
{ ...size },
);
}
Spreading ...size into the ImageResponse options is a small pattern worth stealing directly — it keeps your exported size metadata (which Next.js reads separately to populate og:image:width/og:image:height meta tags) and your actual rendered image dimensions from silently drifting apart. If you hardcode 1200 in two different places and later change one of them, you'll ship an og:image:width tag that lies about the actual image size, which some platforms will penalize by refusing to render a preview at all.
A Real Example: Per-Post Blog Cards
The reason this feature exists at all is dynamic, per-page images, so here's what that actually looks like for something like a blog post:
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getPostBySlug } from "@/lib/posts";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image({ params }: { params: { slug: string } }) {
const post = await getPostBySlug(params.slug);
return new ImageResponse(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 80,
background: "#0f172a",
color: "white",
}}
>
<div
style={{ fontSize: 20, color: "#94a3b8", textTransform: "uppercase" }}
>
{post.category}
</div>
<div style={{ fontSize: 64, fontWeight: 700, lineHeight: 1.1 }}>
{post.title}
</div>
<div style={{ display: "flex", fontSize: 28, color: "#94a3b8" }}>
By {post.author}
</div>
</div>,
{ ...size },
);
}
Because opengraph-image.tsx receives the same route params a regular page.tsx would, generating a distinct card per post is just a data-fetch away — no separate image-generation pipeline, no cron job pre-rendering thumbnails, no third-party screenshot service billed per request.
The Constraints That Actually Bite
The 500KB bundle ceiling. Everything your ImageResponse call touches — the JSX, any imported CSS-in-JS, every font buffer, every embedded image — counts against a 500KB limit. This is easy to blow through without noticing, because a single .ttf file at a couple of weights can eat most of that budget on its own. If you're loading three font weights plus an italic variant, you may already be close to the ceiling before you've written a single line of layout. The fix is almost always to fetch fonts and images at request time from a URL rather than bundling them in, or to trim down to the one or two weights you actually use in the image.
Unsupported CSS fails silently or throws, never gracefully degrades. display: grid, most CSS animations, and a long tail of modern CSS features simply aren't implemented by Satori. There's no polyfill and no fallback — the property is either ignored or the whole render throws. If your image "just doesn't look right" and you can't figure out why, the first thing to check is whether you've reached for a layout property that isn't flexbox, absolute positioning, or one of the handful of other supported primitives. Cross-reference against Satori's own CSS support list rather than assuming standard CSS knowledge transfers.
Fonts should be read once at module scope, not per-request. The docs explicitly call this out: font data doesn't depend on the incoming request, so reading it inside your Image() function means re-reading the same bytes off disk (or re-fetching over the network) on every single invocation. Hoist the readFile/fetch call to module scope, outside the exported function, so it runs once when the module loads rather than once per request.
Runtime matters more than it first appears. ImageResponse's Satori/Resvg pipeline is WebAssembly-based and works on both the Node.js and Edge runtimes, but cold-start behavior differs meaningfully between the two — Edge tends to have snappier cold starts for this specific workload since there's no full Node process to spin up. If your generated images are on a hot path (say, every page load pulls a fresh preview), it's worth benchmarking both runtimes rather than assuming the default is optimal for your traffic pattern.
Debugging a Broken Layout
Because there's no live DOM to inspect, debugging ImageResponse output takes a different workflow than debugging a normal page:
- Set
debug: truein the options object and redeploy or re-request. Every box in your layout gets an outlined border, which usually makes it obvious immediately which nested<div>isn't sizing the way you expect. - Isolate the problem in the Vercel OG Playground, an interactive sandbox that runs the exact same Satori engine in the browser. Pasting your JSX there gets you instant visual feedback without redeploying anything.
- Simplify aggressively. If a complex nested layout isn't rendering right, strip it down to the smallest reproduction, since Satori's error messages for unsupported CSS are not always specific about which property or which element triggered the failure.
Key Takeaways
| Question | Answer |
|---|---|
What does ImageResponse actually do? | Converts a JSX + CSS tree into a rasterized PNG using Satori (layout/SVG) and Resvg (rasterization) — no headless browser involved |
| Where can I use it? | Inside any Route Handler, or inside file-based metadata conventions like opengraph-image.tsx and twitter-image.tsx |
| What CSS is supported? | A subset centered on flexbox and absolute positioning — no display: grid, no CSS animations |
| How do custom fonts work? | Read the font file's bytes yourself (ttf/otf/woff only, no woff2) into an ArrayBuffer and pass it via fonts |
| What's the hard limit to watch for? | A combined 500KB budget across JSX, CSS, fonts, and images |
| How do I debug a bad layout? | Set debug: true for outlined boxes, or reproduce in the Vercel OG Playground |
| Where should font/image fetches live? | At module scope, read once — never inside the per-request Image() function |
ImageResponse is one of those APIs that looks like a small utility but quietly removes an entire category of infrastructure — no screenshot service, no Puppeteer container, no pre-rendered image pipeline to keep in sync with your content. Once you internalize that it's a constrained flexbox renderer rather than a full browser engine, most of its rough edges (the CSS subset, the bundle ceiling, the font-buffer requirement) stop being surprising and start being just the shape of the tool.


