
Next.js opengraph-image and twitter-image
The image that shows up when a link to your site is shared on Slack, Twitter/X, or iMessage isn't magic — it's read from specific <meta> tags (og:image, twitter:image) that your page either has or doesn't. opengraph-image and twitter-image are the file conventions Next.js provides to generate those tags correctly, per route segment, without you hand-authoring meta tags or juggling image dimensions and MIME types yourself.
Static Image Files
Placing a correctly-named image file in any route segment gives that segment its own social preview image, distinct from every other segment:
| File convention | Supported file types |
|---|---|
opengraph-image | .jpg, .jpeg, .png, .gif |
twitter-image | .jpg, .jpeg, .png, .gif |
opengraph-image.alt | .txt |
twitter-image.alt | .txt |
Next.js evaluates the file and generates the corresponding tags automatically:
<meta property="og:image" content="<generated>" />
<meta property="og:image:type" content="<generated>" />
<meta property="og:image:width" content="<generated>" />
<meta property="og:image:height" content="<generated>" />
<meta name="twitter:image" content="<generated>" />
<meta name="twitter:image:type" content="<generated>" />
<meta name="twitter:image:width" content="<generated>" />
<meta name="twitter:image:height" content="<generated>" />
A hard file-size limit is worth knowing before you build the rest of your pipeline around a specific image: twitter-image must not exceed 5MB, and opengraph-image must not exceed 8MB — limits imposed by Twitter/X and Facebook respectively, not by Next.js itself. Exceed either, and the build fails outright rather than silently shipping an oversized image — this is a build-time hard stop, not a runtime warning you might miss.
Alt Text as a Sibling File
Rather than an inline prop, alt text for a static image file is its own sibling text file:
About Acme
<meta property="og:image:alt" content="About Acme" />
Same pattern applies identically to twitter-image.alt.txt.
Generating Images with Code
For images that need to reflect actual page content — a blog post's title rendered directly into the preview image, rather than one static generic graphic for the whole site — opengraph-image.tsx or twitter-image.tsx can default-export a function, typically built on the ImageResponse API from next/og:
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 },
],
},
);
}
That module-level readFile call for the font — executed once, at module scope, rather than inside the Image function — is a deliberate pattern worth internalizing beyond just this specific example: a local asset that doesn't depend on request data should be read once and reused, not re-read on every invocation. The docs frame this as a "predictable values" caching pattern that applies broadly across Next.js's caching model, not just here.
Reflecting Real Page Data
Combine params with a live data fetch to generate genuinely per-page images:
import { ImageResponse } from "next/og";
export const alt = "About Acme";
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 fetch(`https://.../posts/${slug}`).then((res) =>
res.json(),
);
return new ImageResponse(
<div
style={{
fontSize: 48,
background: "white",
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
{post.title}
</div>,
{ ...size },
);
}
By default this generated image is statically optimized — but because it depends on external data, you control whether it stays static or revalidates by configuring the fetch call's own caching options, or by setting the route segment's revalidate config directly, the same levers you'd use for any other data-dependent static content.
Using Local Image Assets in a Generated Image
If you need an actual raster asset (a logo) embedded inside a generated image rather than pure CSS/text styling, read it from disk and pass it as a base64 data URI:
import { ImageResponse } from "next/og";
import { join } from "node:path";
import { readFile } from "node:fs/promises";
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={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<img src={logoSrc} height="100" />
</div>,
);
}
Place the local asset path relative to your project root, not relative to the source file itself — an easy mistake if you're used to relative imports resolving against the file that contains them.
An ArrayBuffer also technically works as an <img src> value here, since the rendering engine behind next/og (Satori) supports it — but because TypeScript's own <img> typings follow the strict HTML spec (which doesn't include ArrayBuffer as a valid src), you'll need a @ts-expect-error comment to silence the type error if you go this route. It's a real, working feature; it's just outside what the type system officially sanctions.
Props
params (optional)
Same shape and behavior as elsewhere in the App Router — a promise resolving to the dynamic segment values from the root down to wherever the file is colocated:
| Route | URL | params |
|---|---|---|
app/shop/opengraph-image.js | /shop | undefined |
app/shop/[slug]/opengraph-image.js | /shop/1 | Promise<{ slug: '1' }> |
app/shop/[tag]/[item]/opengraph-image.js | /shop/1/2 | Promise<{ tag: '1', item: '2' }> |
If you're using generateImageMetadata to produce multiple images from one file, the function also receives an id prop — a promise resolving to the id from the corresponding generateImageMetadata entry.
Returns
The function should return a Response — ImageResponse satisfies this contract directly, which is why it's the recommended default rather than constructing a Response by hand.
Config Exports
| Option | Type |
|---|---|
alt | string |
size | { width: number; height: number } |
contentType | string (image MIME type) |
export const alt = "My images alt text";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function Image() {}
Each maps directly onto its corresponding meta tag — alt becomes og:image:alt, size becomes og:image:width/og:image:height, contentType becomes og:image:type — again with no manual tag-writing on your part.
Route Segment Config Applies Here Too
opengraph-image and twitter-image are, mechanically, specialized Route Handlers — meaning they accept the same route segment configuration options (dynamic, revalidate, and the rest) as ordinary pages and layouts, and are cached by default under exactly the same rule every other metadata-file Route Handler follows: cached unless a Request-time API or dynamic config forces otherwise.
Version History
| Version | Changes |
|---|---|
v16.0.0 | params became a promise resolving to an object |
v13.3.0 | opengraph-image and twitter-image introduced |
Key Takeaways
| Aspect | Detail |
|---|---|
| Static forms | .jpg, .jpeg, .png, .gif — plus a sibling .alt.txt for alt text |
| Size limits | 8MB for opengraph-image, 5MB for twitter-image — exceeding either fails the build |
| Dynamic forms | .js/.ts/.tsx default-exporting a function, typically using ImageResponse |
| Local assets in generated images | Read once at module scope for anything request-independent; path is relative to project root |
| Caching | Statically optimized/cached by default, like other metadata-file Route Handlers |
| Config exports | alt, size, contentType — map directly onto the corresponding meta tags |
The two-tier design here — a dead-simple static file for the common case, a full code-generation path for anything that needs to reflect real content — mirrors the same pattern Next.js uses across its whole metadata-file family. Reach for the static file first; reach for ImageResponse the moment a single fixed image genuinely stops being good enough for every page on your site.


