Skip to content

OG images that render your font, not Noto

ImageResponse has no system fonts and silently falls back. Load a static TTF from disk, and know why woff2 and variable fonts fail.

MDX blog4 min readships at docs/solutions/blog-mdx/og-images-that-render-your-font.md

Tags: opengraph · image-response · satori · fonts · nextjs

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 tw prop; inline style is 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.
  • opacity works, box-shadow and filter mostly 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.