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
Self Learner
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
ImageResponsefromnext/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,.pngand.gif. opengraph-imagefiles must be under 8 MB, or the build fails.- An image in the
appfolder is used by every page below it. A more specific one wins:app/about/opengraph-image.pngis used for/aboutinstead 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:
ImageResponsecomes fromnext/og. It takes JSX and turns it into a PNG picture. You describe the image with<div>elements and inlinestyleobjects, 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
paramsas a Promise, exactly like a page, so youawaitit to get the bookid. alt,sizeandcontentTypeare optional exports. Next.js uses them to write theog:image:alt,og:image:width,og:image:heightandog:image:typetags.{ ...size }passes the same width and height toImageResponse, so the tags and the real picture always match.generateStaticParamsworks here too. With it,npm run buildmakes 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 needsdisplay: 'flex'. Text and an expression side by side count as two children (see Common mistakes). - Use inline
styleobjects. Your Tailwind classes andglobals.cssdo 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,.otfor.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:
- Open the image URL directly (
/books/dune/opengraph-image) to see the picture. - 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
metadataBasein the root layout so image URLs are absolute and point to your real domain. - A static
app/opengraph-image.png(1200 by 630) plusopengraph-image.alt.txtgives every page a card. A file deeper in the tree overrides it for that part of the site. opengraph-image.jswithImageResponsefromnext/oggenerates a card from JSX.paramsis a Promise youawait.- Export
alt,sizeandcontentTypeto fill the image tags, andgenerateStaticParamsto draw the images at build time. - Satori supports flexbox and inline styles only. Any
divwith several children needsdisplay: 'flex'. - Next.js fills the
twitter:tags from your OG image unless you add atwitter-imagefile.
// 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.
Thank you for your feedback!
We appreciate you helping us improve.
What could we improve? (Optional)
Read aloud
Ready