ImageResponse is a constructor from next/og that renders a small JSX tree into a PNG on the server. It exists so that social cards can carry real data, such as a post title, instead of one static picture for every page. You return it from an image file convention or from a Route Handler.
The examples below were verified with Next.js 16.3 in an App Router project on the Node.js runtime, using a production build and the default caching model, with the Cache Components flag off. The import path is next/og, which is where it moved in Next.js 14.
The smallest working image route
Place the file next to the route it belongs to. The default export returns the image, and the extra exports become the meta tags that describe it.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
export const alt = 'Acme blog post'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'Those three exports are metadata about the picture, not the picture itself. Next.js reads them to write the image type, width, height, and alt attributes into the page's Open Graph tags, so the tags can never disagree with the file.
The default export produces the image. Styles are inline objects here, and pulling them into a variable keeps these files readable as they grow.
// app/blog/[slug]/opengraph-image.tsx
const card = {
width: '100%',
height: '100%',
display: 'flex',
background: '#0b0b0b',
color: '#ffffff',
fontSize: 64,
}
export default function Image() {
return new ImageResponse(<div style={card}>Acme</div>, size)
}Requesting the route serves a PNG with a 200 status, and the page that owns it renders the matching Open Graph tags. Passing the exported size object as the options argument is what keeps the rendered canvas and the declared dimensions in sync.
The style object lives in the same image file, above the default export. A card is read as a thumbnail in most feeds, so a headline around 64 pixels is a sensible starting point, and it is worth checking the result at the size people actually see.
Adding the route's own data
The image function receives the same params as the page, as a promise. That is the whole mechanism behind per-post cards.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/app/lib/posts'
type Props = { params: Promise<{ slug: string }> }
export default async function Image({ params }: Props) {
const { slug } = await params
const post = await getPost(slug)
return new ImageResponse(<div style={card}>{post.title}</div>, size)
}A request for the blog post's image route now renders that post's title into the PNG. Use the same deduplicated data function the page uses, which the article on generateMetadata covers, so the record is fetched once per request rather than once per consumer.
Guard the values you interpolate. A title of unknown length will overflow the canvas, so clamp it before rendering rather than hoping every record is short.
This file runs on the server only. It never ships to the browser, so reading a database directly from it is fine, and so is using a private key that the page itself would not be allowed to touch.
Custom fonts
Without a font file the renderer falls back to its default, which rarely matches a brand. Fonts are supplied as buffers, and only ttf, otf, and woff are supported.
// app/blog/[slug]/opengraph-image.tsx
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
const inter = await readFile(join(process.cwd(), 'assets/Inter-SemiBold.ttf'))
export default function Image() {
return new ImageResponse(<div style={card}>Acme</div>, {
...size,
fonts: [{ name: 'Inter', data: inter, weight: 600, style: 'normal' }],
})
}The file is read once when the module loads rather than on every render, since its contents do not depend on the request. Reference the font by the name you registered, using a font family in your style object.
Keep the path relative to the project root through the current working directory, as shown, because the compiled output does not sit next to your source file.
Declare the font entry inline in the options object rather than in a separate variable. The weight field expects specific numeric values, and a detached array widens the type enough to fail the build with a type error.
Everything the image needs counts toward a 500KB limit, including fonts and embedded images. Subset a large font or load fewer weights when you approach it.
Layout rules that actually apply
This is not a browser. The renderer supports flexbox and a subset of CSS properties, and it throws on a violation rather than rendering something odd.
Two failures account for most of them. The first appears when an element has several children and no explicit display value.
Error: Expected <div> to have explicit "display: flex", "display: contents", or "display: none" if it has more than one child node.The second appears when you reach for a layout system that does not exist here, which is a common reflex after writing normal CSS.
Error: Invalid value for CSS property "display". Allowed values: "flex" | "block" | "contents" | "none" | "-webkit-box". Received: "grid".Both throw wherever the image renders. For a prerendered image route that is during the build, so a broken card cannot ship silently; for a request-time route such as the handler below, it is a failed request instead. Build the layout with nested flex containers, and set the display property on every element that wraps more than one child.
Generating images from a Route Handler
Sometimes the image is not tied to one route, for example a card generated from a query string for a documentation search or a share link. A Route Handler is the right place for that, and it is a public endpoint.
// app/api/og/route.tsx
import { ImageResponse } from 'next/og'
const card = { width: '100%', height: '100%', display: 'flex', background: '#111' }
export async function GET(request: Request) {
const title = new URL(request.url).searchParams.get('title')?.slice(0, 80)
if (!title) {
return new Response('Missing title', { status: 400 })
}
return new ImageResponse(<div style={card}>{title}</div>, { width: 1200, height: 630 })
}This file defines its own style and size, since the exports of an image file convention do not reach it. A request without the parameter answers with 400, and a valid one returns a PNG. The length clamp matters more than it looks, because an unbounded parameter is an invitation to render arbitrary text on an image that carries your domain.
Treat this endpoint like any other public route. It costs server work per request, so cache it or restrict it if the URL space is unbounded.
Build time or request time
Next.js prerenders these image routes when it can. The build output tells you which mode each one is in, and the difference is visible as a static entry versus one rendered on demand.
An image route without a dynamic segment is prerendered at build. A route under a dynamic segment renders per request, even when the page above it uses generateStaticParams, because the image is its own route.
// app/blog/[slug]/opengraph-image.tsx
export function generateStaticParams() {
return [{ slug: 'hello-world' }]
}Adding that export to the image file itself moves it into the prerendered group, which the build output confirms by listing the concrete image path. Do this when the set of routes is known and small, and leave it out when the catalog is large or changes often.
Common mistakes
Each of these produces either a failed build or a card that looks wrong in the one place you cannot easily inspect.
- Importing from the old location instead of
next/og, which stopped being correct in Next.js 14. - Writing class names and expecting a stylesheet to apply, when only inline styles and a CSS subset are read.
- Reading a font file inside the image function, so the file is loaded on every render.
- Interpolating an unclamped title, which overflows the canvas instead of wrapping neatly.
- Shipping a query-driven image endpoint with no validation and no length limit.
Rune AI
Key Insights
- Import ImageResponse from next/og and return it from an opengraph-image file or a Route Handler.
- The default size is 1200 by 630, and the exported size, alt, and contentType values become the meta tags.
- Only flexbox and a CSS subset work, and unsupported layout throws a clear error where the image renders.
- Read font files once at module scope, using ttf, otf, or woff.
- Image routes under a dynamic segment render per request unless the file exports generateStaticParams.
Frequently Asked Questions
Where does ImageResponse come from?
Why does my image fail to build with a display error?
Are generated OG images created at build time or per request?
Can I use Tailwind classes inside the image?
Conclusion
ImageResponse turns a small JSX tree into a PNG on the server, which is what makes per-post social cards practical. Keep the markup flexbox-only, load fonts once at module scope, validate any input that reaches a Route Handler version, and decide deliberately whether each image is generated at build time or per request.
More in this topic
`generateMetadata` Explained with Real Examples
What generateMetadata does, when it runs, and how to use it for real routes: awaited params, deduplicated data fetching, extending parent metadata, and returning a 404 from metadata.
Canonical URLs in Next.js: `metadataBase`, `alternates.canonical`, and Dynamic Pages
How canonical URLs work in the Next.js App Router: setting metadataBase once, writing alternates.canonical per route, handling dynamic segments, and what happens when the base URL is missing.
Open Graph and Twitter Card Metadata in Next.js
How to write Open Graph and Twitter card metadata in the Next.js App Router: the openGraph and twitter fields, automatic card defaults, article tags, and image merge rules.