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

WorksDoes not workAlternative
flexbox, padding, border-radiusgrid and floatnested flex
Inline stylesTailwind classesan explicit style object
Local imagesfiles read per request without a cachestatic import or data URL
Text with a loaded fonta system font picked by namean 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.