Back to CourseIntermediate10 min read
Next JS /

Open Graph Images and Social Cards

Make Book Nook links look great when shared, with a static opengraph-image file and per-book cards generated by ImageResponse from next/og.

Bishal Biswas

Bishal Biswas

Self Learner

Published on: October 25, 2026
Intermediate Topic

What a social card is

Paste a link into WhatsApp, Slack, Discord, LinkedIn or X and, a second later, a little preview appears: a picture, a title and a short description. That preview is a social card. The app builds it by reading special tags in your page’s <head>, called Open Graph tags. Open Graph is a simple standard (started by Facebook) that most apps now understand.

The most important tags look like this:

<meta property="og:title" content="The Hobbit | Book Nook" />
<meta property="og:description" content="Bilbo Baggins leaves his cosy home..." />
<meta property="og:image" content="https://booknook.example.com/books/the-hobbit/opengraph-image" />

The title and description come from the metadata you set in Page Titles, Descriptions and Metadata. This lesson is about the picture, the OG image. A link with a good image gets far more clicks than a plain grey box.

You will:

  • tell Next.js your site’s real address with metadataBase
  • add one static OG image for the whole site
  • generate a different card for every book with ImageResponse from next/og
  • learn the layout rules that image generation follows

Step 1: set metadataBase

Social apps fetch your image from their servers, so the image URL must be absolute: a full address that starts with https://. A path like /opengraph-image.png is not enough. metadataBase is the base address Next.js puts in front of relative URLs in your metadata.

Add it to the root layout, together with a few site-wide Open Graph values:

// app/layout.js
export const metadata = {
  metadataBase: new URL('https://booknook.example.com'),
  title: {
    template: '%s | Book Nook',
    default: 'Book Nook',
  },
  description: 'A small online bookshop with hand-picked stories for every reader.',
  openGraph: {
    siteName: 'Book Nook',
    type: 'website',
  },
};

Replace booknook.example.com with your real domain once you deploy. Many projects read it from an environment variable, for example new URL(process.env.SITE_URL ?? 'http://localhost:3000'). See Environment Variables.

If you forget metadataBase, Next.js guesses http://localhost:3000 and prints a warning:

metadataBase property in metadata export is not set for resolving social open graph or twitter images, using "http://localhost:3000".

A social app on the internet cannot open your laptop’s localhost, so the card would show no image. (On Vercel, Next.js uses your deployment address instead, but setting metadataBase yourself is still clearer.)

Step 2: add a static OG image for the whole site

The quickest way to get an OG image is a plain picture file with a special name. Make an image 1200 pixels wide and 630 pixels tall (the size most apps expect), save it as opengraph-image.png, and put it straight in the app folder:

app/
├── layout.js
├── page.js
├── opengraph-image.png        the picture
└── opengraph-image.alt.txt    a short text description of it

The .alt.txt file holds the alt text, words that describe the image for screen readers and for apps that cannot show it:

Book Nook logo above a shelf of colourful books

That is all. Restart npm run dev if it was running, open / and view the page source. Next.js added the tags for you, including the size it read from the file:

<meta property="og:image" content="https://booknook.example.com/opengraph-image.png?..." />
<meta property="og:image:alt" content="Book Nook logo above a shelf of colourful books" />
<meta property="og:image:type" content="image/png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />

(The ?... at the end is a short hash that changes when the image changes, so apps do not keep showing an old picture.)

A few rules for image files:

  • Allowed types are .jpg, .jpeg, .png and .gif.
  • opengraph-image files must be under 8 MB, or the build fails.
  • An image in the app folder is used by every page below it. A more specific one wins: app/about/opengraph-image.png is used for /about instead of the root one.

What about X (Twitter)?

X has its own tags (twitter:image and friends). If you want a different picture there, add a twitter-image.png file next to the OG one (it must be under 5 MB). If you do not, Next.js also fills in the twitter: tags from your Open Graph image, and sets twitter:card to summary_large_image, so one file covers both.

Step 3: a generated card for every book

One picture for the whole shop is fine, but it would be much nicer if sharing /books/dune showed a card that says Dune in big letters. Drawing 4 images by hand is boring, and drawing 400 is impossible. Instead, you can generate the image with code.

Create opengraph-image.js inside the book’s folder:

app/books/[id]/
├── page.js
└── opengraph-image.js
// app/books/[id]/opengraph-image.js
import { ImageResponse } from 'next/og';
import { getBook, getBooks } from '@/lib/books';

// Image metadata: Next.js turns these into og:image tags
export const alt = 'A Book Nook book card';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';

// Make the images at build time, one per book
export async function generateStaticParams() {
  const books = await getBooks();
  return books.map((book) => ({ id: book.id }));
}

export default async function Image({ params }) {
  const { id } = await params;
  const book = await getBook(id);
  const title = book ? book.title : 'Book Nook';
  const author = book ? book.author : '';

  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          padding: 80,
          background: '#fef3c7',
          color: '#431407',
        }}
      >
        <div style={{ fontSize: 32 }}>Book Nook</div>
        <div style={{ fontSize: 80, fontWeight: 700, marginTop: 24 }}>{title}</div>
        <div style={{ fontSize: 40, marginTop: 16 }}>{`by ${author}`}</div>
      </div>
    ),
    { ...size }
  );
}

Here is what is going on:

  • ImageResponse comes from next/og. It takes JSX and turns it into a PNG picture. You describe the image with <div> elements and inline style objects, like a tiny web page.
  • The default export is a function that returns the image. Next.js calls it and serves the result.
  • It gets params as a Promise, exactly like a page, so you await it to get the book id.
  • alt, size and contentType are optional exports. Next.js uses them to write the og:image:alt, og:image:width, og:image:height and og:image:type tags.
  • { ...size } passes the same width and height to ImageResponse, so the tags and the real picture always match.
  • generateStaticParams works here too. With it, npm run build makes all four pictures ahead of time, and the build output lists them:
├   /books/[id]/opengraph-image
│ ├ ● /books/the-hobbit/opengraph-image
│ ├ ● /books/pride-and-prejudice/opengraph-image
│ ├ ● /books/dune/opengraph-image
│ └ ● /books/the-little-prince/opengraph-image

Without generateStaticParams, the image is drawn on demand when a social app first asks for it.

Open http://localhost:3000/books/the-hobbit/opengraph-image in your browser. You see the card itself, a cream rectangle that says “The Hobbit by J.R.R. Tolkien”. Now view the source of /books/the-hobbit:

<meta property="og:title" content="The Hobbit | Book Nook"/>
<meta property="og:image" content="https://booknook.example.com/books/the-hobbit/opengraph-image?cd9a4e940ad4b131"/>
<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 Book Nook book card"/>
<meta name="twitter:card" content="summary_large_image"/>
<meta name="twitter:image" content="https://booknook.example.com/books/the-hobbit/opengraph-image?cd9a4e940ad4b131"/>

Every book page now points to its own card, and the og:title matches the page title from the metadata lesson.

Step 4: the layout rules for ImageResponse

The picture is not drawn by a browser. It is drawn by a small library called Satori, which understands only part of HTML and CSS. Keep these rules in mind:

  • Use flexbox. display: 'flex' works. display: 'grid' does not.
  • Every <div> with more than one child needs display: 'flex'. Text and an expression side by side count as two children (see Common mistakes).
  • Use inline style objects. Your Tailwind classes and globals.css do not reach the image, because it is not part of your web page.
  • Keep it small. Everything the image needs (code, fonts, pictures) must stay under 500 KB.
  • Fonts must be .ttf, .otf or .woff. By default a built-in font is used.

Using your own font

To use a custom font, put the font file in your project (for example assets/Merriweather-Bold.ttf), read it once at the top of the file, and pass it in the options:

// app/books/[id]/opengraph-image.js
import { ImageResponse } from 'next/og';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';

// Read the font once, when the file loads (it never changes)
const merriweather = await readFile(join(process.cwd(), 'assets/Merriweather-Bold.ttf'));

export default async function Image({ params }) {
  // ...same as before...
  return new ImageResponse(
    (
      <div style={{ fontFamily: 'Merriweather', display: 'flex' /* ... */ }}>
        {title}
      </div>
    ),
    {
      ...size,
      fonts: [{ name: 'Merriweather', data: merriweather, style: 'normal', weight: 700 }],
    }
  );
}

process.cwd() is the project folder, so the path starts from there, not from the app folder.

Using a picture inside the card

You can use <img> inside the JSX. A full URL works, and so does a local file turned into a data URL (the picture’s bytes written as text):

// app/opengraph-image.js
import { ImageResponse } from 'next/og';
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';

const logoData = await readFile(join(process.cwd(), 'public/logo.png'), 'base64');
const logoSrc = `data:image/png;base64,${logoData}`;

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', alignItems: 'center', justifyContent: 'center', background: '#fffbeb' }}>
        <img src={logoSrc} height="200" />
      </div>
    ),
    { ...size }
  );
}

Use either opengraph-image.png or opengraph-image.js in a folder, not both, so there is never any doubt about which card a page gets.

Step 5: check your cards

You cannot test a social card from localhost, because the social apps cannot reach your computer. While you build:

  1. Open the image URL directly (/books/dune/opengraph-image) to see the picture.
  2. View the page source and check the og: tags and that the URLs start with your real domain.

After you deploy (see Deploying to Vercel), paste a book link into a chat with yourself, or use the preview tools that LinkedIn and Facebook offer for developers. Apps cache cards for a while, which is one more reason the ?hash on the URL is useful.

Common mistakes

Text and a value in the same div

<div style={{ fontSize: 40 }}>by {author}</div>

To React, this is two children: the text "by " and the value of author. Satori refuses it:

Error: Expected <div> to have explicit "display: flex", "display: contents", or "display: none" if it has more than one child node.

Join them into one string with a template literal, or add display: 'flex' to that div:

<div style={{ fontSize: 40 }}>{`by ${author}`}</div>

Using CSS Grid or Tailwind classes

display: 'grid' and className="text-4xl" do nothing in an OG image. Rebuild the layout with flexbox and inline style objects.

No metadataBase

The tags point to http://localhost:3000/... and the warning about metadataBase appears in the terminal. Add metadataBase: new URL('https://your-domain') to the root layout’s metadata.

A page’s openGraph object hides the image settings

You set openGraph: { title: 'About Book Nook' } in a page and the siteName disappeared. Metadata merges shallowly, so the page’s openGraph replaced the layout’s whole object. Spread a shared object in, as shown in Page Titles, Descriptions and Metadata. Image files like opengraph-image.png are not affected: file-based metadata always wins over the openGraph.images field.

Quick recap

  • Social apps read Open Graph tags (og:title, og:description, og:image) to build link previews.
  • Set metadataBase in the root layout so image URLs are absolute and point to your real domain.
  • A static app/opengraph-image.png (1200 by 630) plus opengraph-image.alt.txt gives every page a card. A file deeper in the tree overrides it for that part of the site.
  • opengraph-image.js with ImageResponse from next/og generates a card from JSX. params is a Promise you await.
  • Export alt, size and contentType to fill the image tags, and generateStaticParams to draw the images at build time.
  • Satori supports flexbox and inline styles only. Any div with several children needs display: 'flex'.
  • Next.js fills the twitter: tags from your OG image unless you add a twitter-image file.
script.js

// app/books/[id]/opengraph-image.js: a social card for every book
import { ImageResponse } from 'next/og';
import { getBook, getBooks } from '@/lib/books';
// These exports become og:image:alt, og:image:width/height and og:image:type
export const alt = 'A Book Nook book card';
export const size = { width: 1200, height: 630 };
export const contentType = 'image/png';
// Draw one image per book during "npm run build"
export async function generateStaticParams() {
  const books = await getBooks();
  return books.map((book) => ({ id: book.id }));
}
export default async function Image({ params }) {
  const { id } = await params; // params is a Promise, like in pages
  const book = await getBook(id);
  const title = book ? book.title : 'Book Nook';
  const author = book ? book.author : '';
  // Satori draws this JSX: flexbox and inline styles only
  return new ImageResponse(
    (
      <div
        style={{
          width: '100%',
          height: '100%',
          display: 'flex',
          flexDirection: 'column',
          justifyContent: 'center',
          padding: 80,
          background: '#fef3c7',
          color: '#431407',
        }}
      >
        <div style={{ fontSize: 32 }}>Book Nook</div>
        <div style={{ fontSize: 80, fontWeight: 700, marginTop: 24 }}>{title}</div>
        {/* One string, not "by {author}", so the div has a single child */}
        <div style={{ fontSize: 40, marginTop: 16 }}>{`by ${author}`}</div>
      </div>
    ),
    { ...size }
  );
}
// --- app/layout.js needs: metadataBase: new URL('https://booknook.example.com')

Was this explanation helpful?

Your feedback helps us improve the content for everyone.