
Next.js generateImageMetadata
Most of the time, a favicon or an Open Graph image in a Next.js app is a single static file sitting next to a route: one icon.png, one opengraph-image.png, done. But that assumption breaks down the moment you need more than one variant of the same image for the same route — an icon that has to ship at several sizes for different devices, a dark-mode and light-mode version of the same favicon, or an Open Graph image that has to be generated per-locale or per-product from data you don't have at build time. generateImageMetadata exists specifically for that case: it lets a single file describe and generate a whole set of images for one route segment, instead of forcing you to hand-roll a separate file per variant.
It's a small, narrow API — the kind of function most Next.js developers will use once or twice a year, if that — but when you need it, there isn't really a substitute. This article walks through exactly how it works, what it expects you to return, how the generated images are actually requested by the browser, and the patterns worth knowing before you reach for it.
The Problem It Solves
Say you want your app's favicon to look different in dark mode and light mode, matching the pattern browsers already support via prefers-color-scheme media queries in a real <link> tag. With a single static icon.png, you can't do that — Next.js's automatic icon convention will only ever emit one icon per file. You could work around this by manually writing multiple differently-named icon files and wiring up the <head> tags by hand in generateMetadata, but that reintroduces exactly the boilerplate the Metadata API conventions were built to remove.
generateImageMetadata solves this by letting one icon.tsx (or opengraph-image.tsx, or twitter-image.tsx — any of the dynamic image conventions) describe a list of images it intends to produce, and Next.js takes care of generating a separate <link> or <meta> tag for each one, each pointing to its own generated image URL.
The Function Signature
generateImageMetadata is an exported function you place in the same file as your dynamic image generator (icon.tsx, opengraph-image.tsx, etc.). It receives one optional argument:
// icon.tsx
export function generateImageMetadata({
params,
}: {
params: { slug: string };
}) {
// ...
}
params is the same dynamic route parameters object you'd see anywhere else in the App Router — everything from the root segment down to wherever this file lives. If the file isn't inside a dynamic segment, params is undefined.
| Route | URL | params |
|---|---|---|
app/shop/icon.js | /shop | undefined |
app/shop/[slug]/icon.js | /shop/1 | { slug: '1' } |
app/shop/[tag]/[item]/icon.js | /shop/1/2 | { tag: '1', item: '2' } |
That's the entire input surface. There's no searchParams, no request object, nothing else — which makes sense, since favicons and Open Graph images are generated well outside the context of a specific incoming request; they're metadata about the route itself, not about any one visit to it.
What You Return
generateImageMetadata must return an array. Each item in that array describes one image Next.js should generate, and the shape is deliberately minimal:
| Field | Type | Required |
|---|---|---|
id | string | Yes |
alt | string | No |
size | { width: number; height: number } | No |
contentType | string | No |
The only field you actually have to supply is id. Everything else is optional metadata that ends up in the generated <link>/<meta> tag — alt becomes the alt text, size becomes the sizes attribute, contentType becomes the MIME type Next.js expects the response to be.
// icon.tsx
import { ImageResponse } from "next/og";
export function generateImageMetadata() {
return [
{
contentType: "image/png",
size: { width: 48, height: 48 },
id: "small",
},
{
contentType: "image/png",
size: { width: 72, height: 72 },
id: "medium",
},
];
}
That id is the load-bearing piece of this whole API. It's not just a label — it's how Next.js correlates each entry in this array back to a specific invocation of your image-generating default export.
How the id Actually Gets Used
The same file that exports generateImageMetadata must also have a default export — the function that actually produces each image, using something like ImageResponse. For every object returned by generateImageMetadata, Next.js calls the default export once, passing that entry's id back in as a prop:
// icon.tsx (continued)
export default async function Icon({ id }: { id: Promise<string | number> }) {
const iconId = await id;
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 88,
background: "#000",
color: "#fafafa",
}}
>
Icon {iconId}
</div>,
);
}
As of Next.js 16, id arrives as a Promise, not a plain value — you have to await it before you can branch on it, exactly like params elsewhere in the App Router. This is a change worth knowing about if you're reading an older tutorial or Stack Overflow answer that shows id as a synchronous string; on 16.x that code will still technically run (destructuring a promise doesn't throw), but iconId will be a Promise object rather than the value you expect, and your image generation logic will silently misbehave.
params follows the same pattern in the default export — it's also a promise there, separate from the (non-promise) params object passed into generateImageMetadata itself:
export default async function Icon({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
// ...
}
It's easy to assume both functions in the same file share an identical params shape and forget that one is awaited and the other isn't — that inconsistency isn't a bug, but it does trip people up on first use.
A Complete Example: Per-Product Open Graph Images
The pattern that makes generateImageMetadata actually earn its keep is generating a variable number of images from external data — something a static file fundamentally can't do. Here's a product page that generates one Open Graph image per product photo, using real data instead of a hard-coded array:
// app/products/[id]/opengraph-image.tsx
import { ImageResponse } from "next/og";
import { getCaptionForImage, getOGImages } from "@/app/utils/images";
export async function generateImageMetadata({
params,
}: {
params: { id: string };
}) {
const images = await getOGImages(params.id);
return images.map((image, idx) => ({
id: idx,
size: { width: 1200, height: 600 },
alt: image.text,
contentType: "image/png",
}));
}
export default async function Image({
params,
id,
}: {
params: Promise<{ id: string }>;
id: Promise<number>;
}) {
const productId = (await params).id;
const imageId = await id;
const text = await getCaptionForImage(productId, imageId);
return new ImageResponse(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 64,
background: "white",
}}
>
{text}
</div>,
);
}
Notice that generateImageMetadata here is itself async — it's completely fine (and common) for it to fetch data before deciding how many images to return. That's the entire point: the number of images and their captions are driven by data that doesn't exist until the request is being handled, not baked in at build time. Every product with a different number of photos ends up with a different number of Open Graph images, all generated by the same two functions.
Practical Patterns Worth Knowing
Dark-mode and light-mode icon pairs. A very common real use is generating two variants of the same favicon so you can emit both a media="(prefers-color-scheme: light)" and a media="(prefers-color-scheme: dark)" icon link, matching what a hand-written <link> tag could do but with Next.js managing the URLs:
export function generateImageMetadata() {
return [
{ id: "light", contentType: "image/png", size: { width: 32, height: 32 } },
{ id: "dark", contentType: "image/png", size: { width: 32, height: 32 } },
];
}
export default async function Icon({ id }: { id: Promise<string> }) {
const variant = await id;
const background = variant === "dark" ? "#000" : "#fff";
const color = variant === "dark" ? "#fff" : "#000";
return new ImageResponse(
<div style={{ width: "100%", height: "100%", background, color }}>N</div>,
);
}
Next.js doesn't automatically know to attach the media attribute for you based on the id alone — you'd typically pair this with metadata configured elsewhere, or lean on the convention that browsers pick the last matching icon in DOM order. Worth testing directly in a browser dev tools element inspector rather than assuming it "just works."
Full favicon size sets. Rather than manually maintaining favicon-16x16.png, favicon-32x32.png, apple-touch-icon.png and so on as static files, you can generate the whole set from one source image at request time (or have it cached after the first request), keeping a single source of truth for the artwork instead of several exported PNGs that can drift out of sync after a rebrand.
Localized Open Graph images. Combined with an [locale] dynamic segment, generateImageMetadata can return one image per supported locale, each rendering the page title and description in that locale's language, without duplicating the file per language.
Common Mistakes
Forgetting to await id or params in the default export. As covered above, both arrive as promises in current Next.js versions. Code that destructures them directly and uses the result as if it were already resolved won't throw — it'll just silently produce a broken image, which is a frustrating thing to debug because there's no stack trace pointing at the real cause.
Returning duplicate id values. Since id is what Next.js uses to route a request to the right image, two entries with the same id will collide — only one of them will actually be reachable, and which one "wins" isn't something worth relying on. Always derive id from something that's genuinely unique per image (an index, a slug, a locale code).
Expensive data fetching with no caching consideration. Because generateImageMetadata can be async and hit a database or API, it's easy to accidentally make every single page load re-run an expensive query just to compute the metadata list — even before any image is actually requested. If the list of images rarely changes, this is a good candidate for the same caching primitives ('use cache', unstable_cache, or a simple in-memory memoization) you'd use for any other expensive Server Component data fetch.
Assuming this replaces static image conventions entirely. If your route only ever needs one icon, generateImageMetadata is unnecessary complexity — a plain static icon.png file (or a icon.tsx with a single default export and no generateImageMetadata at all) does the same job with less code. Reach for this API specifically when the number of images is variable or data-dependent, not as a default habit for every dynamic image file.
Key Takeaways
| Question | Answer |
|---|---|
What does generateImageMetadata return? | An array of objects, each requiring at minimum an id, plus optional alt, size, and contentType |
| How does Next.js know which image to generate? | It calls your file's default export once per returned entry, passing that entry's id back in as a promise |
Is id synchronous? | No — as of Next.js 16, both id and params in the default export are promises and must be awaited |
| Which files can use it? | Any dynamic image-generation file: icon.tsx, apple-icon.tsx, opengraph-image.tsx, twitter-image.tsx |
| When should I reach for it? | When the number of images for a route is variable or depends on external data — not for a single static image |
generateImageMetadata is a small piece of the Metadata API, but it's the piece that turns "one icon per route" into "as many images as the data actually calls for" — without asking you to hand-write the <link> tags to make that happen.


