React
Generated OG images in the App Router
An opengraph-image.tsx file next to the route produces the PNG through ImageResponse. What Satori supports, fonts to load, the duplicate.
Key points
- opengraph-image.tsx next to the page covers that route and its children.
- ImageResponse uses Satori: flexbox and inline styles, no grid and no Tailwind classes.
- Fonts go in as an ArrayBuffer, or the text renders in a face you did not choose.
- The file convention wins over openGraph.images: never declare the image twice.
A generated Open Graph image is an opengraph-image.tsx file next to the route: Next.js runs it and returns a PNG. The name is a convention, not a choice, and the file covers the page and its child routes.
The smallest file
// app/blog/[slug]/opengraph-image.tsx
import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image({ params }: { params: Promise }) {
const { slug } = await params;
return new ImageResponse(
(
ui.eneawork.it/blog
{slug.replace(/-/g, " ")}
),
size,
);
}
The file exports size, contentType and an async component. If it reads params or an uncached fetch, the image is generated on every request; otherwise Next.js produces it once at build time.
What Satori does not do
| Works | Does not work | Alternative |
|---|---|---|
| flexbox, padding, border-radius | grid and float | nested flex |
| Inline styles | Tailwind classes | an explicit style object |
| Local images | files read per request without a cache | static import or data URL |
| Text with a loaded font | a system font picked by name | an ArrayBuffer read from the file |
Where the font goes
// inside the function that renders the image
const font = await readFile(
join(process.cwd(), "public/fonts/inter-semibold.ttf"),
);
return new ImageResponse(
Titolo,
{
...size,
fonts: [{ name: "Inter", data: font, weight: 600, style: "normal" }],
},
);
Without fonts the text renders in a face you did not choose, and the weights do not match the site. Load the file once and reuse it across every image function.
The duplicate to avoid
If you declare openGraph.images in generateMetadata and also keep opengraph-image.tsx, the convention wins and your image disappears. Pick one path: the file for generated images, metadata for static ones.
The pieces of the scene come from the catalogue: hero, badge and typography recombine into inline styles.
Dynamic metadata is in dynamic metadata, the title in how to write a title tag and the description in a meta description that works. Comparison: Astro vs Next.js.
curl -sI http://localhost:3000/blog/hello/opengraph-image
Known limitation: the result is a raster PNG. Text below 24 px reads badly on LinkedIn and generation burns CPU: if the scene never changes, a static file is faster.
Frequently asked questions
Where does the opengraph-image file go?
In the route folder, next to page.tsx. It covers that page and the child routes that do not declare their own.
Can I use Tailwind inside ImageResponse?
No. Satori accepts inline styles: turn your tokens into a JavaScript object and pass that.
Why does the font come out wrong?
Because you did not pass a font. ImageResponse does not read the site fonts: load the file as an ArrayBuffer and declare it in the fonts option.
Is the image generated on every request?
Only if it reads dynamic data such as params or an uncached fetch. With static content Next.js generates it once at build time.
Do I need twitter-image too?
Only if you want a different image from the Open Graph one. When the format is fine, Twitter uses the same image.