Open Graph images
Give every Next.js page a link-preview picture: opengraph-image files, ImageResponse to turn JSX into a PNG, one image per page, and custom fonts.
- 01Add a link-preview picturePut an opengraph-image file in a folder, set metadataBase, and read the og:image tags Next.js writes for every page below it.
- 02Draw a picture from JSXWrite opengraph-image.tsx with ImageResponse, using flexbox, your own font, and params for one picture per page.
- 03Know when it's drawnTell from the build's route table whether a picture is drawn once during the build or on each request, and fix the errors it stops on.
The picture in the tea shop's link preview
Paste a link into a chat app, and it often draws a card: a big picture, the page's title and a line of text. That card is a link preview. The chat app makes it from tags in the page's <head>, the Open Graph tags that start with og:. The Metadata API wrote og:title and og:description. The picture comes from one more tag, og:image: the address of an image file.
In Next.js, you don't have to write that tag. You save the picture in app with a special name, opengraph-image, and Next.js does the rest. Here is the tea shop with app/opengraph-image.png, a green card of 1200 × 630 pixels, the size the docs use:
app/opengraph-image.png
An image file: the picture above, not text. Download opengraph-image.png
GET /200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 15 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Tea shop</title>
<meta name="description" content="Three teas from one small shop.">
<meta property="og:title" content="Tea shop">
<meta property="og:description" content="Three teas from one small shop.">
<meta property="og:image" content="http://localhost:3000/opengraph-image.png?opengraph-image.1pjey01m9pivv.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="A white teacup on green, and the words Tea shop">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Tea shop">
<meta name="twitter:description" content="Three teas from one small shop.">
<meta name="twitter:image" content="http://localhost:3000/opengraph-image.png?opengraph-image.1pjey01m9pivv.png">
<meta name="twitter:image:alt" content="A white teacup on green, and the words Tea shop">
<meta name="twitter:image:type" content="image/png">
<meta name="twitter:image:width" content="1200">
<meta name="twitter:image:height" content="630">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<main>
<h1>Tea shop</h1>
<p>Green tea, mint tea and lemon tea.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>This is a real Next.js 16.4.3 project, built with next build and run with next start. The file tree shows the picture itself. Next to it is app/opengraph-image.alt.txt, one line of text that describes the picture for people who can't see it.
The Network tab shows the home page's <head>. After the title and the description come the picture's tags, and nobody wrote them:
og:image: where the picture is,http://localhost:3000/opengraph-image.png, with a query after the?.og:image:typeimage/png,og:image:width1200andog:image:height630: what kind of file it is, and its size, read from the file.og:image:alt: the text fromopengraph-image.alt.txt.twitter:cardsummary_large_image, andtwitter:imagetags with the same picture. X (once Twitter) reads these; Next.js fills them in from the Open Graph picture.
Pick /opengraph-image.png above the page: the server sent the picture itself, image/png, and the Browser tab shows it. The Terminal tab lists it as a route of its own, ○ /opengraph-image.png.
og: tags in its <head>, then downloads the picture og:image points at. The width and height tell it the picture's shape before it arrives.A bookshop shows covers in its window, not pages. Each book comes with its cover, so nobody has to paste covers into the catalog. A series has one design, and the printer makes each book's cover from it, with that book's title. A special edition has a cover of its own, and that one is shown instead.
- In real life: The cover a bookshop puts in its window
- In Next.js: The picture a chat app shows in a link preview:
og:image - In real life: The cover comes with the book; nobody copies it into the catalog by hand
- In Next.js:
opengraph-image.pngsits in the folder of its pages; Next.js writes the tags - In real life: The catalog lists each cover's size
- In Next.js:
og:image:widthandog:image:height - In real life: A book with its own cover doesn't use the series cover
- In Next.js: A file in
app/teaswins over the one inappfor/teas - In real life: A series prints each book's cover from one design, with the book's title on it
- In Next.js:
opengraph-image.tsxdraws each tea's picture from one file, withparams
A file named opengraph-image in a folder of app is the link-preview picture for every page in that folder and below. Next.js serves it at an address of its own and writes the og:image tags that point at it. The file can be a picture, or code that draws one with ImageResponse.
Image files in app, and the address they need
Other programs read og:image, far from your site, so the tag needs a complete address, with the site's name in it. The first recording has http://localhost:3000, which only works on the computer that ran the server. Open its Terminal tab, and the build said why:
⚠ metadataBase property in metadata export is not set for resolving social open graph or twitter images, using "http://localhost:3000". See https://nextjs.org/docs/app/api-reference/functions/generate-metadata#metadatabasemetadataBase is a field of the root layout's metadata: the site's real address. Next.js puts it in front of every address in the metadata that has to be complete. Without it, the build fell back to localhost and warned. With it:
app/layout.tsximport type { Metadata } from "next"; export const metadata: Metadata = { metadataBase: new URL("https://tea.example"), title: "Tea shop", description: "Three teas from one small shop.",}; export default function RootLayout({ children }: LayoutProps<"/">) { return ( <html lang="en"> <body>{children}</body> </html> );}GET /200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 15 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Tea shop</title>
<meta name="description" content="Three teas from one small shop.">
<meta property="og:title" content="Tea shop">
<meta property="og:description" content="Three teas from one small shop.">
<meta property="og:image" content="https://tea.example/opengraph-image.png?opengraph-image.1pjey01m9pivv.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="A white teacup on green, and the words Tea shop">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Tea shop">
<meta name="twitter:description" content="Three teas from one small shop.">
<meta name="twitter:image" content="https://tea.example/opengraph-image.png?opengraph-image.1pjey01m9pivv.png">
<meta name="twitter:image:alt" content="A white teacup on green, and the words Tea shop">
<meta name="twitter:image:type" content="image/png">
<meta name="twitter:image:width" content="1200">
<meta name="twitter:image:height" content="630">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<main>
<h1>Tea shop</h1>
<p>Green tea, mint tea and lemon tea.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>Line 4 of app/layout.tsx sets metadataBase to https://tea.example, which stands in for the shop's real address (.example is a name kept for examples). Now og:image is https://tea.example/opengraph-image.png?…, and the warning is gone. On Vercel, Next.js can also fall back to the deployment's own address, but setting metadataBase yourself is the sure way.
The part after the ? is a query Next.js adds. In the recordings, the same picture always got the same query, and another picture got another one. Its exact form belongs to the build: never copy it into your code.
A nearer picture wins
An opengraph-image file is for the pages in its own folder and the folders below it. The tea list wants an orange card of its own, so it gets app/teas/opengraph-image.png:
app/teas/opengraph-image.png
An image file: the picture above, not text. Download opengraph-image.png
GET /teas200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Tea shop</title>
<meta name="description" content="Three teas from one small shop.">
<meta property="og:title" content="Tea shop">
<meta property="og:description" content="Three teas from one small shop.">
<meta property="og:image" content="https://tea.example/teas/opengraph-image.png?opengraph-image.041iex6n4_3lb.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Tea shop">
<meta name="twitter:description" content="Three teas from one small shop.">
<meta name="twitter:image" content="https://tea.example/teas/opengraph-image.png?opengraph-image.041iex6n4_3lb.png">
<meta name="twitter:image:type" content="image/png">
<meta name="twitter:image:width" content="1200">
<meta name="twitter:image:height" content="630">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<main>
<h1>Our teas</h1>
<p>Green, mint and lemon.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>Pick each page above the Network tab. / still points at /opengraph-image.png, the green card. /teas points at /teas/opengraph-image.png, the orange one, and its tags don't mention the green card at all. The Terminal tab lists both pictures as routes.
/ gets the green picture from app. /teas gets the orange one from app/teas, the nearest folder above it with a picture, instead of the green one.A picture only for X
A file named twitter-image sets the twitter:image tags alone. Here the shop has a blue card for X:
app/twitter-image.png
An image file: the picture above, not text. Download twitter-image.png
GET /200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 14 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-3-ry682tkyiqk.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Tea shop</title>
<meta name="description" content="Three teas from one small shop.">
<meta property="og:title" content="Tea shop">
<meta property="og:description" content="Three teas from one small shop.">
<meta property="og:image" content="https://tea.example/opengraph-image.png?opengraph-image.1pjey01m9pivv.png">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Tea shop">
<meta name="twitter:description" content="Three teas from one small shop.">
<meta name="twitter:image" content="https://tea.example/twitter-image.png?twitter-image.0pm34ptjewymz.png">
<meta name="twitter:image:type" content="image/png">
<meta name="twitter:image:width" content="1200">
<meta name="twitter:image:height" content="630">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<main>
<h1>Tea shop</h1>
<p>Green tea, mint tea and lemon tea.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>In the Network tab, twitter:image points at /twitter-image.png, and og:image still points at /opengraph-image.png. Each row below is one kind of file, and what it adds to the <head> of its pages:
| The file | What it adds to the <head> | In the recordings |
|---|---|---|
opengraph-image.png (or .jpg, .jpeg, .gif) | og:image with its type, width and height, and the same picture in the twitter:image tags. | The home page's og:image and twitter:image both point at /opengraph-image.png. |
opengraph-image.alt.txt | og:image:alt and twitter:image:alt, with the file's text. | A white teacup on green, and the words Tea shop. |
twitter-image.png | Only the twitter:image tags. og:image stays the same. | twitter:image points at /twitter-image.png; og:image doesn't change. |
opengraph-image.tsx | The same tags, with the width, height, type and alt text from its size, contentType and alt exports. | og:image points at /opengraph-image, with no file ending. |
Picture files can be .jpg, .jpeg, .png or .gif. The docs give two size limits, because the apps that read them do: an opengraph-image file may be up to 8 MB, and a twitter-image file up to 5 MB. A bigger file fails the build.
ImageResponse: JSX in, PNG out
A picture file has to be made in a drawing program, and made again for every change. Instead, opengraph-image can be a .tsx file that draws the picture with code. Its default export returns an ImageResponse, from next/og: a response whose body is a PNG, drawn from JSX.
app/opengraph-image.tsximport { ImageResponse } from "next/og"; export const alt = "The words Tea shop, white on green";export const size = { width: 1200, height: 630 };export const contentType = "image/png"; export default function Image() { return new ImageResponse( ( <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: 80, background: "#2f855a", color: "white", }} > <div style={{ fontSize: 120 }}>Tea shop</div> <div style={{ fontSize: 48 }}>Three teas from one small shop</div> </div> ), size, );}
This response isn't an HTML page: it's an image, image/png, 1200 × 630 pixels, 35.3 kB. Above is the image itself, kept from the recording (as a WebP copy of the same size). Open it full size. Open the Network tab for its headers.
Line 1 imports ImageResponse. Lines 3 to 5 describe the picture: its alt text, its size and its contentType. Next.js turns them into og:image:alt, og:image:width, og:image:height and og:image:type; pick / and open the Network tab to see them. Line 7 is the default export. Line 8 creates the ImageResponse: its first argument, on lines 10 to 24, is the JSX to draw, and its second, on line 26, gives the picture's width and height, the same size as line 4.
The Browser tab shows what /opengraph-image answered: a PNG, 1200 × 630, with the two lines of text on green. The address has no file ending, and that's the address og:image points at.
Open the Terminal tab: ○ /opengraph-image, prerendered as static content. The picture reads nothing from the request, so next build drew it once, and next start sends that PNG to everyone. The Network tab shows x-nextjs-cache: HIT, Next.js's sign that the answer came from what it had stored, and Cache-Control: public, max-age=0, must-revalidate: browsers and chat apps may keep a copy, but must check with the server before using it again.
What the JSX can hold
The JSX looks like a React component's, but no browser draws it. The docs say ImageResponse uses two libraries: Satori turns the JSX and its styles into an SVG, and Resvg turns that SVG into a PNG.
ImageResponse gives your JSX and its style props to Satori, which lays them out with flexbox and draws an SVG. Resvg turns the SVG into a PNG. No browser runs. The recorded /opengraph-image is that PNG: image/png, 1200 × 630.So the JSX follows Satori's rules, not a browser's:
- Styles go in each element's
styleprop, like lines 11 to 20, written as a JavaScript object with camelCase names (justifyContent,fontSize), the way React writes inline styles. - Layout is flexbox. The docs say only flexbox and a subset of CSS work. Line 14 makes the outer box a flex box, and line 15 stacks its children from top to bottom.
- A box with more than one child must say
display: "flex". Delete line 14, and the build stops:
app/opengraph-image.tsximport { ImageResponse } from "next/og"; export const alt = "The words Tea shop, white on green";export const size = { width: 1200, height: 630 };export const contentType = "image/png"; export default function Image() { return new ImageResponse( ( <div style={{ width: "100%", height: "100%", flexDirection: "column", justifyContent: "center", padding: 80, background: "#2f855a", color: "white", }} > <div style={{ fontSize: 120 }}>Tea shop</div> <div style={{ fontSize: 48 }}>Three teas from one small shop</div> </div> ), size, );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Error occurred prerendering page "/opengraph-image". Read more: https://nextjs.org/docs/messages/prerender-errorError: Expected <div> to have explicit "display: flex", "display: contents", or "display: none" if it has more than one child node. at Jt.next (<anonymous>)Export encountered an error on /opengraph-image/route: /opengraph-image, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: nullThe Terminal tab shows where it stopped: Error occurred prerendering page "/opengraph-image", because the build draws this picture. The reason is the next line: Expected <div> to have explicit "display: flex", "display: contents", or "display: none" if it has more than one child node. The outer box holds two lines, so it needs one of the three.
Text counts too. In JSX, {teaCount} teas from one small shop is two children: the number, and the text after it. Here line 25 puts both in one box, and the build stops with the same message:
app/opengraph-image.tsximport { ImageResponse } from "next/og"; export const alt = "The words Tea shop, white on green";export const size = { width: 1200, height: 630 };export const contentType = "image/png"; const teaCount = 3; export default function Image() { return new ImageResponse( ( <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: 80, background: "#2f855a", color: "white", }} > <div style={{ fontSize: 120 }}>Tea shop</div> <div style={{ fontSize: 48 }}>{teaCount} teas from one small shop</div> </div> ), size, );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...Error occurred prerendering page "/opengraph-image". Read more: https://nextjs.org/docs/messages/prerender-errorError: Expected <div> to have explicit "display: flex", "display: contents", or "display: none" if it has more than one child node. at Jt.next (<anonymous>)Export encountered an error on /opengraph-image/route: /opengraph-image, exiting the build.⨯ Next.js build worker exited with code: 1 and signal: nullThe fix is to make one string out of the two, with a template literal, so the box holds one child again:
<div style={{ fontSize: 48 }}>{`${teaCount} teas from one small shop`}</div>The recorded build with that line passes, and its picture says 3 teas from one small shop.
A picture for every tea
Each tea has its page, /teas/mint and /teas/lemon, from app/teas/[id]/page.tsx (Dynamic routes teaches the folder name in brackets). A link to Mint tea should show Mint tea. Put an opengraph-image.tsx in the same folder, and it draws a picture for each tea:
app/teas/[id]/opengraph-image.tsximport { ImageResponse } from "next/og";import { getTea } from "../../teas"; export const alt = "A tea from the tea shop, with its price";export const size = { width: 1200, height: 630 };export const contentType = "image/png"; export default async function Image({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const tea = await getTea(id); return new ImageResponse( ( <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: 80, background: tea?.color, color: "white", }} > <div style={{ fontSize: 120 }}>{tea?.name}</div> <div style={{ fontSize: 48 }}>{`$${tea?.price} a box, from the Tea shop`}</div> </div> ), size, );}
This response isn't an HTML page: it's an image, image/png, 1200 × 630 pixels, 33.5 kB. Above is the image itself, kept from the recording (as a WebP copy of the same size). Open it full size. Open the Network tab for its headers.
Line 8 is the default export again. This time Next.js gives it params, a promise, like a page's. Line 9 waits for it and takes the id, and line 10 loads the tea from app/teas.ts, a stand-in for the shop's database. Line 21 uses the tea's color, line 25 its name, and line 26 its price, as one string. Pick /teas/mint/opengraph-image and /teas/lemon/opengraph-image above the Browser tab: two pictures from one file.
Pick /teas/mint and open the Network tab. Its og:image is https://tea.example/teas/mint/opengraph-image?…: the picture's address follows the page's. Now open the Terminal tab:
└ ƒ /teas/[id]/opengraph-imageƒ means server-rendered on demand. The page lists its teas with generateStaticParams, so each tea's page is ○. But that list belongs to the page. The image route has no list, so the build can't know which teas to draw, and next start draws each picture when someone asks for it: the recorded /teas/mint/opengraph-image came without x-nextjs-cache. To draw them during the build, the image file exports a list of its own:
app/teas/[id]/opengraph-image.tsximport { ImageResponse } from "next/og";import { getTea, getTeas } from "../../teas"; export const alt = "A tea from the tea shop, with its price";export const size = { width: 1200, height: 630 };export const contentType = "image/png"; export async function generateStaticParams() { const teas = await getTeas(); return teas.map((tea) => ({ id: tea.id }));} export default async function Image({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const tea = await getTea(id); return new ImageResponse( ( <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: 80, background: tea?.color, color: "white", }} > <div style={{ fontSize: 120 }}>{tea?.name}</div> <div style={{ fontSize: 48 }}>{`$${tea?.price} a box, from the Tea shop`}</div> </div> ), size, );}$ next build▲ Next.js 16.4.3 (Turbopack)- Cache Components enabled- Partial Prefetching enabled Creating an optimized production build ...✓ Compiled successfully Running TypeScript ... Finished TypeScript ... Collecting page data ...✓ Generating static pages (11/11) Finalizing page optimization ... Route (app)┌ ○ /├ ○ /_not-found├ ○ /opengraph-image├ /teas/[id]│ ├ ◐ /teas/[id]│ ├ ○ /teas/green│ ├ ○ /teas/mint│ └ ○ /teas/lemon└ /teas/[id]/opengraph-image ├ ● /teas/green/opengraph-image ├ ● /teas/mint/opengraph-image └ ● /teas/lemon/opengraph-image ○ (Static) prerendered as static content● (SSG) prerendered as static HTML (uses generateStaticParams)◐ (Partial Prerender) prerendered as static HTML with dynamic server-streamed contentLines 8 to 11 are the same generateStaticParams as the page's. Now the Terminal tab lists ● /teas/green/opengraph-image, ● /teas/mint/opengraph-image and ● /teas/lemon/opengraph-image: prerendered with generateStaticParams. In the Network tab, the mint picture comes with x-nextjs-cache: HIT.
Each page gets the nearest picture above it. Pick a version and read the og:image tags of /teas/mint:
app/opengraph-image.tsximport { ImageResponse } from "next/og"; export const alt = "The words Tea shop, white on green";export const size = { width: 1200, height: 630 };export const contentType = "image/png"; export default function Image() { return new ImageResponse( ( <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: 80, background: "#2f855a", color: "white", }} > <div style={{ fontSize: 120 }}>Tea shop</div> <div style={{ fontSize: 48 }}>Three teas from one small shop</div> </div> ), size, );}GET /teas/mint200
- Response headers
Varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encodingx-nextjs-cacheHITx-nextjs-prerender1x-nextjs-stale-time300X-Powered-ByNext.jsCache-Controls-maxage=31536000Content-Typetext/html; charset=utf-8
The HTML that arrived, one element per line, with the code of its 15 scripts left out
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="preload" as="script" fetchpriority="low" href="/_next/static/chunks/3cfn99dbrcvvp.js">
<script src="/_next/static/chunks/1j_b1xq_woyiy.js" async=""></script>
<script src="/_next/static/chunks/2ki52vdnbo2ft.js" async=""></script>
<script src="/_next/static/chunks/turbopack-2c3mg3r-05n1w.js" async=""></script>
<script src="/_next/static/chunks/1tq6ppe__m8m-.js" async=""></script>
<title>Mint tea</title>
<meta name="description" content="Three teas from one small shop.">
<meta property="og:title" content="Mint tea">
<meta property="og:description" content="Three teas from one small shop.">
<meta property="og:image" content="https://tea.example/opengraph-image?efcbacdc97f655f3">
<meta property="og:image:type" content="image/png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="The words Tea shop, white on green">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="Mint tea">
<meta name="twitter:description" content="Three teas from one small shop.">
<meta name="twitter:image" content="https://tea.example/opengraph-image?efcbacdc97f655f3">
<meta name="twitter:image:alt" content="The words Tea shop, white on green">
<meta name="twitter:image:type" content="image/png">
<meta name="twitter:image:width" content="1200">
<meta name="twitter:image:height" content="630">
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" nomodule=""></script>
</head>
<body>
<div hidden="">
<!--$-->
<!--/$-->
</div>
<!--&-->
<!--&-->
<!--&-->
<main>
<h1>Mint tea</h1>
<p>$<!-- -->3<!-- --> a box.</p>
</main>
<!--$-->
<!--/$-->
<!--/&-->
<!--/&-->
<!--/&-->
<script id="_R_">…</script>
<script src="/_next/static/chunks/3cfn99dbrcvvp.js" async=""></script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
<script>…</script>
</body>
</html>Only app/opengraph-image.tsx exists. /teas/mint has no picture of its own, so it gets the shop's: og:image is https://tea.example/opengraph-image?…, with the alt text from line 3.
The third version is a first look at generateImageMetadata. It returns a list of pictures, each with an id, and Next.js calls the default export once for each, with that id as a promise:
import { ImageResponse } from "next/og";
import { getTea } from "../../teas";
export function generateImageMetadata() {
return [
{ id: "wide", alt: "A tea from the tea shop", size: { width: 1200, height: 630 }, contentType: "image/png" },
{ id: "square", alt: "A tea from the tea shop", size: { width: 600, height: 600 }, contentType: "image/png" },
];
}
export default async function Image({ params, id }: { params: Promise<{ id: string }>; id: Promise<string> }) {
const tea = await getTea((await params).id);
const shape = await id;
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: tea?.color,
color: "white",
fontSize: 96,
}}
>
{tea?.name}
</div>
),
shape === "wide" ? { width: 1200, height: 630 } : { width: 600, height: 600 },
);
}
Lines 4 to 9 list two pictures, wide and square, each with its own alt, size and contentType. Line 13 waits for the id, and line 31 picks the size from it. The recorded /teas/mint has two sets of og:image tags, at /teas/mint/opengraph-image/wide and /teas/mint/opengraph-image/square, and the square one is 600 × 600. The docs' own example of it draws an icon in two sizes.
Fonts and limits inside ImageResponse
Without a font of its own, ImageResponse uses one regular font that comes with it. To draw the shop's name in its own font, you give ImageResponse the font file's bytes. Here it's Lilita One, a free font, saved in the project as assets/LilitaOne-Regular.ttf:
app/opengraph-image.tsximport { readFile } from "node:fs/promises";import { join } from "node:path";import { ImageResponse } from "next/og"; export const alt = "The words Tea shop, white on green";export const size = { width: 1200, height: 630 };export const contentType = "image/png"; const lilita = await readFile(join(process.cwd(), "assets/LilitaOne-Regular.ttf")); export default function Image() { return new ImageResponse( ( <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: 80, background: "#2f855a", color: "white", }} > <div style={{ fontSize: 120, fontFamily: "Lilita One" }}>Tea shop</div> <div style={{ fontSize: 48 }}>Three teas from one small shop</div> </div> ), { ...size, fonts: [{ name: "Lilita One", data: lilita, style: "normal", weight: 400 }], }, );}
This response isn't an HTML page: it's an image, image/png, 1200 × 630 pixels, 32.3 kB. Above is the image itself, kept from the recording (as a WebP copy of the same size). Open it full size. Open the Network tab for its headers.
Lines 1 and 2 import two of Node.js's own modules: readFile reads a file, and join builds a path. Line 9 reads the font. process.cwd() is the project's folder while Next.js runs, so the path starts there. The line runs once, when the module loads, not for each picture: the docs put it there because the font is the same every time. Line 32 passes it in fonts, with a name, and line 26 asks for that name in fontFamily.
Line 27 asks for no font, yet in the Browser tab, real Next.js drew it in Lilita One too. Once you pass fonts, those are the only fonts ImageResponse has. To keep another font for the smaller line, pass its file as well. Compare the two pictures:
app/opengraph-image.tsxNext.jsapp/opengraph-image.tsximport { ImageResponse } from "next/og"; export const alt = "The words Tea shop, white on green";export const size = { width: 1200, height: 630 };export const contentType = "image/png"; export default function Image() { return new ImageResponse( ( <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: 80, background: "#2f855a", color: "white", }} > <div style={{ fontSize: 120 }}>Tea shop</div> <div style={{ fontSize: 48 }}>Three teas from one small shop</div> </div> ), size, );}
This response isn't an HTML page: it's an image, image/png, 1200 × 630 pixels, 35.3 kB. Above is the image itself, kept from the recording (as a WebP copy of the same size). Open it full size. Open the Network tab for its headers.
Line 26 passes only size, with no fonts. Both lines, 22 and 23, use the one regular font that comes with ImageResponse.
A font that lives at an address, not in the project, works the same way: fetch the file and pass await response.arrayBuffer() as data. A next/font font doesn't help here: it makes CSS for web pages, and ImageResponse needs the file's bytes.
Each row is one limit the docs list for ImageResponse, and what to do about it:
| Limit | What the docs say | What to do |
|---|---|---|
| Layout | Only flexbox and a subset of CSS. display: "grid" stops the build. | Lay out with flex boxes. Give every box with more than one child display: "flex". |
| Font files | Only ttf, otf and woff. The docs say ttf and otf are read faster. | Use a ttf or otf file, not the woff2 a web page would use. |
| Size | The docs list a 500 KB limit on what the image needs: your JSX, CSS, fonts and images. | Pass one weight of one font, and small images. |
The grid in the first row is a recorded build too: with display: "grid", it stopped with Invalid value for CSS property "display". and the values it allows.
Which picture does each page need?
Ask two questions. Does the picture already exist as a file? Save it as an opengraph-image file. Is it text and color, the same for a group of pages? One opengraph-image.tsx. Is it different on every page of a dynamic route? Put the opengraph-image.tsx in that route's folder and read params.
- A photo of the shop's front door, taken by a photographer
- A card a designer drew by hand, changed twice a year
- The shop's name in its own font, on its green
- The number of teas in stock, from the database, for the whole site
- Each tea's name, price and color
- Each blog post's title, on the post's own link preview
Sort each link-preview picture the tea shop wants into the kind of file that makes it.
Common mix-ups
- "
og:imagecan be a path like/opengraph-image.png." No. A chat app reads it on its own server, so it needs a complete address. SetmetadataBase, or the build falls back tolocalhostand warns. - "A generated picture is drawn every time someone shares the link." Not when it reads nothing from the request: the build draws it once (
○). Under a dynamic folder, without its owngenerateStaticParams, it's drawn on each request (ƒ). - "The page's
generateStaticParamscovers its picture too." No. The recorded image route stayedƒuntil the image file exported its own. - "Any CSS works in
ImageResponse." No. Flexbox and a subset of CSS. A grid, or a box with two children and nodisplay: "flex", stops the build. - "You need a
twitter-imagefor X." No. Next.js fills thetwitter:imagetags from the Open Graph picture. Add atwitter-imageonly for a different picture. - "A
woff2font works, like on a web page." No. The docs listttf,otfandwoff.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
The shop has this app/layout.tsx and app/opengraph-image.png. What does the home page's og:image start with?
import type { Metadata } from "next";
export const metadata: Metadata = {
metadataBase: new URL("https://tea.example"),
title: "Tea shop",
description: "Three teas from one small shop.",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html lang="en">
<body>{children}</body>
</html>
);
}
https://tea.example/opengraph-image.png, then a query. The recorded home page's og:image is exactly that, with the query Next.js adds after the ?.
The shop has these files. Which file's picture does /teas get in its link preview? Give its path.
app/
layout.tsx metadataBase: https://tea.example
page.tsx
opengraph-image.png a green card: Tea shop
teas/
page.tsx
opengraph-image.png an orange card: Our teas
app/teas/opengraph-image.png, the orange card. It's nearer to /teas than the one in app. The recorded /teas has og:image https://tea.example/teas/opengraph-image.png?….
The build stops on this picture. What does the outer box need instead of display: "grid"?
import { ImageResponse } from "next/og";
export const alt = "The words Tea shop, white on green";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function Image() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "grid",
justifyContent: "center",
padding: 80,
background: "#2f855a",
color: "white",
}}
>
<div style={{ fontSize: 120 }}>Tea shop</div>
<div style={{ fontSize: 48 }}>Three teas from one small shop</div>
</div>
),
size,
);
}
import { ImageResponse } from "next/og";
export const alt = "The words Tea shop, white on green";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default function Image() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: 80,
background: "#2f855a",
color: "white",
}}
>
<div style={{ fontSize: 120 }}>Tea shop</div>
<div style={{ fontSize: 48 }}>Three teas from one small shop</div>
</div>
),
size,
);
}
display: "flex", with flexDirection: "column" to stack the two lines. The recorded build with a grid stopped with Invalid value for CSS property "display"; the flex version is the shop's card.
The build stops with Expected <div> to have explicit "display: flex"…. Fix line 25 without adding a style. How do you make it one child?
import { ImageResponse } from "next/og";
export const alt = "The words Tea shop, white on green";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
const teaCount = 3;
export default function Image() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: 80,
background: "#2f855a",
color: "white",
}}
>
<div style={{ fontSize: 120 }}>Tea shop</div>
<div style={{ fontSize: 48 }}>{teaCount} teas from one small shop</div>
</div>
),
size,
);
}
import { ImageResponse } from "next/og";
export const alt = "The words Tea shop, white on green";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
const teaCount = 3;
export default function Image() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: 80,
background: "#2f855a",
color: "white",
}}
>
<div style={{ fontSize: 120 }}>Tea shop</div>
<div style={{ fontSize: 48 }}>{`${teaCount} teas from one small shop`}</div>
</div>
),
size,
);
}
A template literal: {${teaCount} teas from one small shop} is one string, so the box holds one child. The recorded build passes, and the picture says 3 teas from one small shop.
This file draws each tea's picture on request. What does it need so next build draws all three ahead of time?
import { ImageResponse } from "next/og";
import { getTea } from "../../teas";
export const alt = "A tea from the tea shop, with its price";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export default async function Image({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const tea = await getTea(id);
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: 80,
background: tea?.color,
color: "white",
}}
>
<div style={{ fontSize: 120 }}>{tea?.name}</div>
<div style={{ fontSize: 48 }}>{`$${tea?.price} a box, from the Tea shop`}</div>
</div>
),
size,
);
}
import { ImageResponse } from "next/og";
import { getTea, getTeas } from "../../teas";
export const alt = "A tea from the tea shop, with its price";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export async function generateStaticParams() {
const teas = await getTeas();
return teas.map((tea) => ({ id: tea.id }));
}
export default async function Image({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const tea = await getTea(id);
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: 80,
background: tea?.color,
color: "white",
}}
>
<div style={{ fontSize: 120 }}>{tea?.name}</div>
<div style={{ fontSize: 48 }}>{`$${tea?.price} a box, from the Tea shop`}</div>
</div>
),
size,
);
}
generateStaticParams, exported from the image file itself. The recorded build then lists ● /teas/green/opengraph-image, ● /teas/mint/opengraph-image and ● /teas/lemon/opengraph-image.
The heading on line 26 asks for Lilita One. Which font does the recorded picture use for the line under it?
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { ImageResponse } from "next/og";
export const alt = "The words Tea shop, white on green";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
const lilita = await readFile(join(process.cwd(), "assets/LilitaOne-Regular.ttf"));
export default function Image() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: 80,
background: "#2f855a",
color: "white",
}}
>
<div style={{ fontSize: 120, fontFamily: "Lilita One" }}>Tea shop</div>
<div style={{ fontSize: 48 }}>Three teas from one small shop</div>
</div>
),
{
...size,
fonts: [{ name: "Lilita One", data: lilita, style: "normal", weight: 400 }],
},
);
}
Lilita One. Passing fonts gives ImageResponse only those fonts, so with only Lilita One supplied, both lines use it, including the one without a fontFamily.
Check your understanding
For each question, find the picture's file, then ask whether the build can draw it once.
Question 1 of 8The root layout has no
metadataBase, andapp/opengraph-image.pngexists. What does the recordedog:imagestart with?Choose an answer to see the explanation.
Question 2 of 8
app/opengraph-image.pngis green, andapp/teas/opengraph-image.pngis orange. Which picture does/teasget?Choose an answer to see the explanation.
Question 3 of 8This is
app/opengraph-image.tsx. When is its picture drawn?Read the code, then predictTSXimport { ImageResponse } from "next/og"; export const alt = "The words Tea shop, white on green"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; export default function Image() { return new ImageResponse( ( <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: 80, background: "#2f855a", color: "white", }} > <div style={{ fontSize: 120 }}>Tea shop</div> <div style={{ fontSize: 48 }}>Three teas from one small shop</div> </div> ), size, ); }Choose an answer to see the explanation.
Question 4 of 8The build stops on this file with
Expected <div> to have explicit "display: flex"…. Which box needs it?Read the code, then predictTSXimport { ImageResponse } from "next/og"; export const alt = "The words Tea shop, white on green"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; export default function Image() { return new ImageResponse( ( <div style={{ width: "100%", height: "100%", flexDirection: "column", justifyContent: "center", padding: 80, background: "#2f855a", color: "white", }} > <div style={{ fontSize: 120 }}>Tea shop</div> <div style={{ fontSize: 48 }}>Three teas from one small shop</div> </div> ), size, ); }Choose an answer to see the explanation.
Question 5 of 8This is
app/teas/[id]/opengraph-image.tsx, and it exports nogenerateStaticParams. What does the route table show for it?Read the code, then predictTSXimport { ImageResponse } from "next/og"; import { getTea } from "../../teas"; export const alt = "A tea from the tea shop, with its price"; export const size = { width: 1200, height: 630 }; export const contentType = "image/png"; export default async function Image({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const tea = await getTea(id); return new ImageResponse( ( <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: 80, background: tea?.color, color: "white", }} > <div style={{ fontSize: 120 }}>{tea?.name}</div> <div style={{ fontSize: 48 }}>{`$${tea?.price} a box, from the Tea shop`}</div> </div> ), size, ); }Choose an answer to see the explanation.
Question 6 of 8The project has
app/opengraph-image.pngand notwitter-imagefile. What's in the recordedtwitter:imagetag?Choose an answer to see the explanation.
Question 7 of 8An
ImageResponsegetsfonts: [{ name: "Lilita One", … }], and only the heading setsfontFamily. Which font draws the line below it?Choose an answer to see the explanation.
Question 8 of 8Which of these font files can you pass to
ImageResponse?Choose an answer to see the explanation.
Key takeaways
- An
opengraph-imagefile in a folder ofappis the link-preview picture for its pages. Next.js serves it and writesog:imagewith its type, width, height and alt text. - Set
metadataBasein the root layout. Without it,og:imagepoints atlocalhost, and the build warns. - The nearest picture above a page wins. A
twitter-imagefile changes only thetwitter:imagetags; without one, they use the Open Graph picture. opengraph-image.tsxreturns anImageResponse: JSX and inline styles, laid out with flexbox, drawn into a PNG on the server.- With no request data, the build draws it once (
○). Under a dynamic folder it getsparams; exportgenerateStaticParamsfrom the image file, or it's drawn on each request (ƒ). - Pass a
ttf,otforwofffile's bytes infonts, read once at the top of the module. Those become the only fonts it has.
Remember the one-liner.
Name a picture opengraph-image, or draw it from JSX, and Next.js writes the tags that put it in every link preview.
These links point at release v16.4.3, the version every example on this page was built with. Where localhost came from is getSocialImageMetadataBaseFallback, at lines 35 to 55 of resolve-url.ts: your metadataBase first, then a Vercel deployment's address, then localhost. The ImageResponse class is in lines 43 to 93 of image-response.ts, including the Content-Type and Cache-Control headers the recordings show.
Coming next: Sitemaps, robots and structured data. It helps search engines find every page of the shop, and understand what each one is about.