cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Add 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.
  • 02
    Draw a picture from JSXWrite opengraph-image.tsx with ImageResponse, using flexbox, your own font, and params for one picture per page.
  • 03
    Know 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:

The shop's link-preview picture, saved as app/opengraph-image.pngNext.js
app/
app/opengraph-image.png
The image file app/opengraph-image.png

An image file: the picture above, not text. Download opengraph-image.png

Recorded from Next.js 16.4.3

GET /200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/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:type image/png, og:image:width 1200 and og:image:height 630: what kind of file it is, and its size, read from the file.
  • og:image:alt: the text from opengraph-image.alt.txt.
  • twitter:card summary_large_image, and twitter:image tags 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.

A window for the home page's head holds four tags: og:title Tea shop, og:image, og:image:width 1200 and og:image:height 630. An arrow goes to a chat app's window, which shows a link preview: a big picture, then the title Tea shop and the description.<head>og:title: Tea shopog:image: …pngog:image:width: 1200og:image:height: 630chatthe pictureTea shopThree teas from…a link preview, drawn from the tags
From the recorded home page. A chat app asks for the page, reads the 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.
Real-life analogyBook covers in a bookshop window

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.png sits 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:width and og:image:height
In real life: A book with its own cover doesn't use the series cover
In Next.js: A file in app/teas wins over the one in app for /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.tsx draws each tea's picture from one file, with params
The main idea

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:

The warning in the first build's outputText
⚠ 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#metadatabase

metadataBase 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:

The root layout with metadataBaseNext.js
app/
app/layout.tsx
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>  );}
Recorded from Next.js 16.4.3

GET /200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/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:

A second picture for /teasNext.js
app/teas/
app/teas/opengraph-image.png
The image file app/teas/opengraph-image.png

An image file: the picture above, not text. Download opengraph-image.png

Recorded from Next.js 16.4.3

GET /teas200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/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.

Two image files: app/opengraph-image.png, a green card, and app/teas/opengraph-image.png, an orange card. An arrow from the green file goes to the page slash. An arrow from the orange file goes to the page slash teas. Under slash teas, the green card is crossed out: the nearer file replaced it.Files in appThe page's og:imageapp/opengraph-image.pngapp/teas/opengraph-image.png/: the green card/teas: the orange card/teas: the green cardthe nearest file wins
From the recording with both files. / 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 next to app/opengraph-image.pngNext.js
app/
app/twitter-image.png
The image file app/twitter-image.png

An image file: the picture above, not text. Download twitter-image.png

Recorded from Next.js 16.4.3

GET /200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/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 files, and the tags they add
The fileWhat 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.txtog:image:alt and twitter:image:alt, with the file's text.A white teacup on green, and the words Tea shop.
twitter-image.pngOnly the twitter:image tags. og:image stays the same.twitter:image points at /twitter-image.png; og:image doesn't change.
opengraph-image.tsxThe 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.

The shop's card, drawn from JSX in app/opengraph-image.tsxNext.js
app/
app/opengraph-image.tsx
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,  );}
Recorded from Next.js 16.4.3
The image /opengraph-image answered with, 1200 by 630 pixels, as recorded

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.

Three cards in a row. JSX with style props; an arrow labeled Satori goes to SVG; an arrow labeled Resvg goes to PNG, 1200 by 630.JSX, styleSatoriSVGResvgPNG, 1200 × 630inside ImageResponse, on the server
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 style prop, 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:
The outer box without display: flexNext.js
app/
app/opengraph-image.tsx
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%",          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 failed · Next.js 16.4.3
$ 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: null

The 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:

A number and text in one boxNext.js
app/
app/opengraph-image.tsx
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,  );}
next build failed · Next.js 16.4.3
$ 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: null

The fix is to make one string out of the two, with a template literal, so the box holds one child again:

Line 25, as one stringTSX
<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:

One picture per tea, in app/teas/[id]/opengraph-image.tsxNext.js
app/teas/[id]/
app/teas/[id]/opengraph-image.tsx
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,  );}
Recorded from Next.js 16.4.3
The image /teas/mint/opengraph-image answered with, 1200 by 630 pixels, as recorded

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:

The image route in the build's outputText
└ ƒ /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:

The image file with generateStaticParamsNext.js
app/teas/[id]/
app/teas/[id]/opengraph-image.tsx
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,  );}
Recorded from Next.js 16.4.3
$ 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 content

Lines 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:

Playground: which picture does /teas/mint get?
The tea shop, with this version of its image filesNext.js
app/teas/[id]/
app/opengraph-image.tsx
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,  );}
Recorded from Next.js 16.4.3

GET /teas/mint200

Response headers
Vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch, Accept-Encoding
x-nextjs-cache HIT
x-nextjs-prerender 1
x-nextjs-stale-time 300
X-Powered-By Next.js
Cache-Control s-maxage=31536000
Content-Type text/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>
Try it yourself
One picture in app

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.

Every version was built with next build from Next.js 16.4.3. The Network tab shows the <head> next start sent for /teas/mint: read its og:image tags. Only the image files change. Reset goes back to the first version.

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:

app/teas/[id]/opengraph-image.tsx with generateImageMetadataTSX
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:

The shop's name in Lilita OneNext.js
app/assets/
app/opengraph-image.tsx
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 }],    },  );}
Recorded from Next.js 16.4.3
The image /opengraph-image answered with, 1200 by 630 pixels, as recorded

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:

Playground: the same card, two fonts
The tea shop, with this version of app/opengraph-image.tsxNext.js
app/
app/opengraph-image.tsx
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,  );}
Recorded from Next.js 16.4.3
The image /opengraph-image answered with, 1200 by 630 pixels, as recorded

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.

Try it yourself
The default font

Line 26 passes only size, with no fonts. Both lines, 22 and 23, use the one regular font that comes with ImageResponse.

Both versions were built with next build from Next.js 16.4.3. The Browser tab shows the PNG that next start sent for /opengraph-image. Only the font changes. Reset goes back to the default font.

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:

What ImageResponse can't do
LimitWhat the docs sayWhat to do
LayoutOnly 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 filesOnly 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.
SizeThe 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.

Which kind of picture?
  • 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
Try it yourself
0 of 6 correct

Sort each link-preview picture the tea shop wants into the kind of file that makes it.

Choose a category for every card. You can change an answer at any time; Reset clears them all.

Common mix-ups

  • "og:image can be a path like /opengraph-image.png." No. A chat app reads it on its own server, so it needs a complete address. Set metadataBase, or the build falls back to localhost and 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 own generateStaticParams, it's drawn on each request (ƒ).
  • "The page's generateStaticParams covers 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 no display: "flex", stops the build.
  • "You need a twitter-image for X." No. Next.js fills the twitter:image tags from the Open Graph picture. Add a twitter-image only for a different picture.
  • "A woff2 font works, like on a web page." No. The docs list ttf, otf and woff.

Practice exercises

These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.

Exercise 1 · Warm-upComplete the picture's address

The shop has this app/layout.tsx and app/opengraph-image.png. What does the home page's og:image start with?

Starter codeTSX
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>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upTwo pictures, one page

    The shop has these files. Which file's picture does /teas get in its link preview? Give its path.

    Starter codeText
    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
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeA grid that won't build

      The build stops on this picture. What does the outer box need instead of display: "grid"?

      Starter codeTSX
      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,
        );
      }
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeThree teas, one line

        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?

        Starter codeTSX
        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,
          );
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeDraw every tea's picture during the build

          This file draws each tea's picture on request. What does it need so next build draws all three ahead of time?

          Starter codeTSX
          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,
            );
          }
          

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengeWhich font draws the second line?

            The heading on line 26 asks for Lilita One. Which font does the recorded picture use for the line under it?

            Starter codeTSX
            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 }],
                },
              );
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, find the picture's file, then ask whether the build can draw it once.

              The Open Graph images quiz · 8 questionsScore: first tries count
              1. Question 1 of 8The root layout has no metadataBase, and app/opengraph-image.png exists. What does the recorded og:image start with?

                Choose an answer to see the explanation.

              2. Question 2 of 8app/opengraph-image.png is green, and app/teas/opengraph-image.png is orange. Which picture does /teas get?

                Choose an answer to see the explanation.

              3. Question 3 of 8This is app/opengraph-image.tsx. When is its picture drawn?

                Read the code, then predictTSX
                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,
                  );
                }
                

                Choose an answer to see the explanation.

              4. 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 predictTSX
                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%",
                          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.

              5. Question 5 of 8This is app/teas/[id]/opengraph-image.tsx, and it exports no generateStaticParams. What does the route table show for it?

                Read the code, then predictTSX
                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,
                  );
                }
                

                Choose an answer to see the explanation.

              6. Question 6 of 8The project has app/opengraph-image.png and no twitter-image file. What's in the recorded twitter:image tag?

                Choose an answer to see the explanation.

              7. Question 7 of 8An ImageResponse gets fonts: [{ name: "Lilita One", … }], and only the heading sets fontFamily. Which font draws the line below it?

                Choose an answer to see the explanation.

              8. Question 8 of 8Which of these font files can you pass to ImageResponse?

                Choose an answer to see the explanation.

              Key takeaways

              • An opengraph-image file in a folder of app is the link-preview picture for its pages. Next.js serves it and writes og:image with its type, width, height and alt text.
              • Set metadataBase in the root layout. Without it, og:image points at localhost, and the build warns.
              • The nearest picture above a page wins. A twitter-image file changes only the twitter:image tags; without one, they use the Open Graph picture.
              • opengraph-image.tsx returns an ImageResponse: 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 gets params; export generateStaticParams from the image file, or it's drawn on each request (ƒ).
              • Pass a ttf, otf or woff file's bytes in fonts, 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.

              Read it in Next.js's own code

              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.

              CompleteFrontend Clear concepts. Working examples.