You add an opengraph-image.tsx to your post route, deploy, paste the URL into
a link preview debugger, and the card is legible but wrong: the type is not your
typeface, the weight is off, and if the title contains an em dash or a curly
quote there is a blank box where the character should be.
Nothing errored. That is the confusing part.
Why it happens
ImageResponse renders JSX with Satori, which converts it to SVG, then
rasterises it. Satori runs in a sandbox with no access to system fonts:
there is no "Helvetica" to fall back to, no font file on the machine it can
find, and no CSS @font-face mechanism. It ships one bundled fallback face so
that images render at all rather than throwing.
So fontFamily: "Inter" is not a request that can fail loudly. It is a name
Satori looks up in the list of fonts you gave it, finds nothing, and quietly
substitutes the fallback.
The blank box for a curly quote is the same bug at a smaller scale: the fallback face genuinely does not contain that glyph.
The fix: hand it the bytes
Commit a static font file and read it from disk at build time.
// src/app/blog/[slug]/opengraph-image.tsx
import fs from "node:fs/promises";
import path from "node:path";
import { ImageResponse } from "next/og";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const alt = "Article preview";
async function loadFonts() {
const file = path.join(process.cwd(), "assets", "og", "display.ttf");
try {
const data = await fs.readFile(file);
return [{ name: "Display", data, weight: 600 as const, style: "normal" as const }];
} catch {
return undefined; // render in the fallback rather than failing the build
}
}
export default async function Image({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = getPost(slug);
const fonts = await loadFonts();
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 72,
...(fonts ? { fontFamily: "Display" } : {}),
backgroundColor: "rgb(9, 9, 11)",
color: "rgb(250, 250, 250)",
}}
>
<div style={{ display: "flex", fontSize: 28, opacity: 0.7 }}>My Site</div>
<div style={{ display: "flex", fontSize: 64, lineHeight: 1.1 }}>{post?.title}</div>
</div>
),
{ ...size, fonts },
);
}
The name you pass is the name you must use in fontFamily. It has nothing to
do with the font's internal name: call it "Display" and ask for "Display".
The three file-format rules
.ttf or .otf, never .woff2. Satori cannot decompress woff2. If you
download a font from a webfont CDN you will almost certainly get woff2, and it
will fail or render as the fallback. Get the static desktop files.
Not a variable font. A variable .ttf carries axes rather than one
instance, and Satori renders it wrong or not at all. Export or download the
static instance for each weight you use: Inter-SemiBold.ttf, not
Inter-VariableFont_slnt,wght.ttf.
One file per weight. fonts is an array; each entry has its own data,
weight and style. Ask for fontWeight: 700 without a 700 entry and Satori
uses the closest one it has, which is usually not what you drew in Figma.
Why it is read from disk and not fetched
You will see examples that fetch() the font from a CDN inside the image
handler. That means every OG image build depends on a third-party host being up,
adds latency, and breaks on a machine with no network. fs.readFile from
process.cwd() reads a file you committed: it works offline, it is
deterministic, and it is the pattern the Next.js docs use.
Keep the file small. Subsetting a font to Latin plus punctuation takes it from ~300kb to ~40kb, which matters because the file is read on every image build.
Layout gotchas that look like font bugs
Satori implements a subset of CSS, and its error messages are unhelpful.
- Every element with more than one child needs
display: flex. Satori has no block layout. A<div>with two children and no display throws "Expected <div> to have explicit display". - No CSS classes. Tailwind utilities do nothing here unless you use the
twprop; inlinestyleis the reliable path. This is the one place in a token-driven codebase where colours are written literally, so keep it to two or three and keep them in step with your tokens. - Text does not wrap the way you expect. Long titles overflow instead of
ellipsing. Clamp the string in JavaScript, or set an explicit width and
lineClamp. opacityworks,box-shadowandfiltermostly do not.
Confirming it worked
The image is generated at build time, so check it locally before deploying:
bun run build
bun run start
# open http://localhost:3000/blog/<slug>/opengraph-image
Then, after deploying, paste the post URL into a social debugger and force a re-scrape: every platform caches OG images aggressively, and a stale card is the most common reason people think the fix did not work.
Finally, do not also set openGraph.images in generateMetadata. The
opengraph-image file convention already injects the tag; setting both gives
you two og:image tags and consumers pick whichever they like.