# 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.

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.

```tsx
// 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:

```bash
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.

---

Agentic Boilerplate: A Next.js repo your agent already knows. Free during launch, then $99 once.

- Site map for agents: https://agenticboilerplate.com/llms.txt
- Public API: https://agenticboilerplate.com/openapi.json
- Contact: agenticstudio@gmail.com
