opengraph-image
Set a route segment's Open Graph image from a static file or a server-rendered ImageResponse.
export default function Image(props: { params?: Promise<Record<string, string | string[]>>; id?: Promise<string> }): Response | Promise<Response>- Added in Next.js 13.3.0
Parameters
| Parameter | Type | Description |
|---|---|---|
$paramsoptional | Promise<Record<string, string | string[]>> | Promise resolving to the dynamic route parameters from the root segment down to the segment this file sits in. Undefined when no segment above it is dynamic. |
$idoptional | Promise<string> | Only passed when the file also exports generateImageMetadata. Resolves to the id of the image currently being generated. |
$altoptional | string | Named export. Alt text emitted as og:image:alt. For a static image file, put the same text in opengraph-image.alt.txt beside it. |
$sizeoptional | { width: number; height: number } | Named export. Emitted as og:image:width and og:image:height. Spread it into the ImageResponse options so the rendered bitmap matches the declared size. |
$contentTypeoptional | string | Named export. Image MIME type emitted as og:image:type, for example 'image/png'. |
Return value
ResponseA Response carrying the image bytes. An ImageResponse from next/og satisfies this type.
Errors
The build fails if a static opengraph-image file exceeds 8MB.
Changelog
- PHP 16.0.0params is now a promise that resolves to an object, so it must be awaited.
- PHP 13.3.0opengraph-image and twitter-image conventions introduced.
Put an opengraph-image.png in any route segment and Next.js emits the og:image tags for every page under it, resolving the URL and reading the real dimensions off the file. Swap in opengraph-image.tsx and you get a Route Handler that renders the card per request or per build.
When to reach for the code version
- Each blog post or product needs its own title burned into the card.
- The card must reflect live data, such as a price or a score.
- You want one template instead of hand-exporting hundreds of PNGs.
What trips people up
next/og renders with Satori, not a browser. It supports a subset of CSS: flexbox only, no CSS grid, no floats, and any element with more than one child must set display: 'flex' explicitly or rendering throws. Tailwind class names do nothing unless you use the tw prop.
<img> sources must be absolute URLs or data URIs — a /logo.png path resolves to nothing. Read local assets once at module scope with readFile rather than per request. Finally, the exported size only writes the meta tags; pass it into the ImageResponse options too, or the tags will lie about the bitmap.
Example
import { ImageResponse } from 'next/og'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
export const alt = 'About Acme'
export const size = {
width: 1200,
height: 630,
}
export const contentType = 'image/png'
const interSemiBold = await readFile(
join(process.cwd(), 'assets/Inter-SemiBold.ttf')
)
export default async function Image() {
return new ImageResponse(
(
<div
style={{
fontSize: 128,
background: 'white',
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
About Acme
</div>
),
{
...size,
fonts: [
{
name: 'Inter',
data: interSemiBold,
style: 'normal',
weight: 400,
},
],
}
)
}When to use it
- Render each blog post's title onto a 1200x630 card at build time
- Show a product's name and price in the link preview shared on Slack
- Ship one branded template instead of exporting share images by hand
- Add a static opengraph-image.png plus its .alt.txt for the marketing home page
Frequently asked questions
Why is my Next.js Open Graph image blank or failing to render?
Almost always a Satori CSS limitation: any element with more than one child needs an explicit display: 'flex', and grid, float and unsupported properties are ignored or throw. Check that images use absolute URLs or data URIs and that a custom font is passed in the fonts option.
How do I generate a different share image for every blog post?
Put opengraph-image.tsx inside the dynamic segment, for example app/blog/[slug]/opengraph-image.tsx, and read the slug from the awaited params prop. Next.js generates one image per prerendered path and wires the URL into that page's og:image tag.
Do I need both opengraph-image and twitter-image?
No — X falls back to og:image when no twitter:image is present, so a single opengraph-image covers both. Add twitter-image only when you want a different crop, aspect ratio or wording on X specifically.
More examples
Per-post card built from the route params
Since Next.js 16 `params` is a promise, so `await params` before reading `slug` — destructuring it directly gives undefined and the card renders the wrong post. The outer div sets flexDirection: 'column' because it has three children; leaving display off an element with multiple children is the single most common Satori render error.
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { getPost } from '@/app/lib/posts'
export const alt = 'Acme blog post'
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 getPost(slug)
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: 64,
background: '#0b0b0b',
color: 'white',
}}
>
<div style={{ fontSize: 28, opacity: 0.7 }}>acme.com/blog</div>
<div style={{ fontSize: 72, lineHeight: 1.1 }}>{post.title}</div>
<div style={{ fontSize: 28, opacity: 0.7 }}>{post.author}</div>
</div>
),
{ ...size }
)
}Static image plus alt text file
No default export is involved: Next.js reads the PNG's real width, height and MIME type off disk and writes them into the tags, and picks up the alt text from the sibling .alt.txt. The image applies to /about and everything nested under it, so a segment without its own file inherits the nearest one above it.
// File layout, no code required:
//
// app
// ├── layout.tsx
// ├── page.tsx
// └── about
// ├── opengraph-image.png (1200 x 630, under 8MB)
// ├── opengraph-image.alt.txt (contains: About Acme)
// └── page.tsx
//
// Emitted into <head> for /about:
//
// <meta property="og:image" content="<generated>" />
// <meta property="og:image:alt" content="About Acme" />
// <meta property="og:image:type" content="image/png" />
// <meta property="og:image:width" content="1200" />
// <meta property="og:image:height" content="630" />
export {}Embedding a local logo
The file is read once at module scope, so the base64 conversion happens on first import rather than on every render. The path is resolved from `process.cwd()` — the project root — not from this file's directory. A bare src="/logo.png" would produce a blank area, because Satori has no page origin to resolve relative URLs against.
// app/opengraph-image.tsx
import { ImageResponse } from 'next/og'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
export const alt = 'Acme'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
const logoData = await readFile(join(process.cwd(), 'logo.png'), 'base64')
const logoSrc = `data:image/png;base64,${logoData}`
export default async function Image() {
return new ImageResponse(
(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'white',
}}
>
<img src={logoSrc} height="200" />
</div>
),
{ ...size }
)
}
Discussion