# Per-article Open Graph cards in Astro with Satori — CJK fonts and X's title overlay

> Generating a 1200x630 Open Graph card per article and locale at build time in Astro with Satori and resvg, with Japanese and Chinese fonts kept legible.

- Source: https://oharu121.com/blog/astro-satori-open-graph-card-cjk-fonts-x-overlay/
- Published: 2026-10-17T23:01:44+09:00
- Tags: Astro, Satori, Open Graph

---
## Introduction

I saw a Qiita article shared on X, and its preview card had Qiita's logo and the article's title drawn into the picture. Links to this blog showed only a cover image, which looked plain beside it.

Covers here come from a shared registry keyed by topic, so every Astro article unfurled as the same black Astro logo. When I built that registry I decided the cover could serve as the Open Graph image directly, and [wrote that I had budgeted for Satori and didn't need it](/blog/astro-content-collections-no-database-mdx-tags-cjk-fonts/).

Every article now gets **its own 1200×630 card in each of its three languages, drawn at build time by Satori and resvg**: the title, the site's seal, and the topic cover kept as a small tile. A shared link now says which article it is, and the card is redrawn on the next build whenever the title or cover changes.

*Image: A post on X linking to an article on this blog. The card shows the seal and site name, the Astro cover as a tile, and the article title and subtitle in large type; X's dark title strip runs along the empty bottom of the card*

*The finished card on X. X's own title strip sits in the band the card leaves empty.*

## What other sites put in og:image

`og:image` is the Open Graph meta tag naming the picture a crawler shows when a link is shared. **Sites whose posts mostly have no image of their own generate a card with the title on it, and sites where every post has a cover use that cover.** I asked Claude Code how other sites decide.

These sites generate the card, each in its own way.

GitHub [built a service for this](https://github.blog/open-source/git/framework-building-open-graph-images/) because most people never uploaded a custom image for their repository. It fills an HTML template with the repository's name, description, owner avatar and counts, and screenshots it with Puppeteer.

*Image: GitHub's card for the repository oharu121/cli-ascii-logo: the repository name in large type, its description, the owner's avatar, and counts for contributors, issues, stars and forks*

*GitHub's card for one of my repositories.*

Zenn's card shows the title, the author's avatar and name, and the Zenn logo. Its `og:image` is a Cloudinary URL whose parameters lay the title over a fixed background as a text layer.

*Image: Zenn's card for a Japanese article: the title on a white card over a blue-to-purple gradient, the author's avatar and name at the bottom left, and the Zenn logo at the bottom right*

*Zenn's card for an article I posted there.*

Qiita's card puts its logo above the title and the author's avatar and handle below it. Its `og:image` is an imgix URL that composites a background, the avatar and the title text the same way.

*Image: Qiita's card for the same Japanese article: the Qiita logo at the top, the title in three lines with only 証 on the third, and the author's avatar and handle below*

*The same article on Qiita. Its card wraps 検証 across two lines.*

**Zenn and Qiita hand the drawing to a third-party image service**, Cloudinary and imgix respectively. Such a service composes an image on request from the parameters in its URL and serves the result from its own CDN.

On note, for example, where every post has a cover, the image an author uploads for a post becomes its Open Graph image unchanged.

### Why this blog shares one cover per topic

**This blog's posts have no art of their own, yet it used the cover as the Open Graph image the way note does.**

I never designed an image per article. Every article on a topic shares one cover, partly so the article list shows each article's topic at a glance, and partly because a different cover style on every post would look cluttered.

Shared on social media, though, **that cover says nothing about which of my articles the link is**. Here is the same pnpm article unfurled in Slack, before and after:

*Image: A Slack link preview for a pnpm 12 article: the title and description as text, then the shared pnpm logo cover as the image*

*Before: the image is the pnpm cover, which every pnpm article on the site also uses.*

*Image: The same Slack preview with the generated card: the site's seal and name, the pnpm cover as a small tile, and the article title and subtitle set in large type*

*After: the generated card carries the title, so the image identifies the article on its own.*

Slack prints `og:title` above the image, so on Slack the title now appears twice, which I think is acceptable.

## The card: title, seal, and the topic image as a tile

**The card keeps the cover as a small tile in the top-right corner.** Every article already has a cover, so I put the title and the cover tile on the same card.

Each cover is a 1200×630 landscape logo card. The left side already carries the blog's own seal and name, so I shrank the whole cover to 320×168 and put it on the right as **a topic badge**.

*Figure — CardAnatomy: The title block gets whatever height is left between the tile and the line X's title strip starts at.*

The rest of the layout follows from where X puts its own text. X prints `og:title` across the bottom of a large link card, the dark strip in the X screenshot at the top of this article.

So **nothing on the card may end below 504px, the top of the bottom fifth**, and the title takes the space between the tile and that line.

Titles on this blog mostly follow the pattern `Main — subtitle`. The card splits at the dash: the main title sets large and bold, from 80px down to 48px, and the subtitle sets at 30px in a muted grey. A 12px vermilion bar across the top matches the seal in the blog's logo.

*Image: Three generated cards stacked: an English title about Astro link icons with the Astro tile, a Japanese title about GROUP BY ROLLUP with the LLM tile, and a Traditional Chinese title about a Claude Code logo skill with the LLM tile*

*One card per language. The zh-TW card is set in Noto Sans TC, the other two in Noto Sans JP, which covers Latin as well.*

## One prerendered endpoint per locale

Each card is written during `pnpm build` by an Astro endpoint, a `.png.ts` route. Astro calls its `GET` once and saves the returned PNG as a static file, which is what X and other sites fetch when they ask for the card.

**The card route and the article page take their list of articles from the same function, `getArticlePaths`**, so every published article gets exactly one card and a draft gets neither.

*Figure — CardPipeline: One list of articles feeds both routes, once per language.*

Each locale gets a one-line wrapper:

```ts title="src/pages/ja/og/[slug].png.ts"
import type { APIContext } from "astro";
import { ogCardPaths, ogCardResponse } from "@/lib/og-card";

export const getStaticPaths = () => ogCardPaths("ja");
export const GET = (context: APIContext) => ogCardResponse("ja", context);
```

The card is drawn by one module, `src/lib/og-card.ts`, in two steps:

1. **Satori lays it out.** Satori is an open-source library from Vercel that turns a layout described in JSX and CSS into SVG. No React is needed: the layout is written directly as plain `{ type, props }` objects and handed to Satori.
2. **resvg turns it into a PNG.** `@resvg/resvg-js` rasterises Satori's SVG into a PNG, because X accepts JPG, PNG, WEBP and GIF for `og:image` but not SVG.

> **HEADS UP**
>
> **What Satori buys**
>
> Laying a card out the way CSS does, then rasterising it, is not cheap. `node-canvas` can paint pixels directly, but line wrapping, multi-line ellipsis and flexbox would all be hand-written. Satori does the layout and outputs SVG; resvg makes the PNG. `@vercel/og` wraps both steps in one call, but I use them directly for the flexibility.

```ts title="src/lib/og-card.ts"
const tree = el("div", { width: 1200, height: 630, display: "flex", flexDirection: "column", padding: 64 }, [
  el("div", { display: "flex", justifyContent: "space-between" }, [
    sealAndSiteName,
    { type: "img", props: { src: tileDataUri, width: 320, height: 168 } },
  ]),
  el("div", { fontSize: size, fontWeight: 700, lineClamp: maxLines }, title),
  el("div", { fontSize: 30, color: "#475569", lineClamp: 2 }, subtitle),
]);
const svg = await satori(tree, { width: 1200, height: 630, fonts });
const png = new Resvg(svg).render().asPng();
```

Satori, resvg and the fonts are devDependencies, used only at build time, so none of them is bundled into the serverless function the site deploys beside its static files. Each card took about 25ms in the build log.

For each card, `og-card.ts` does these steps in order:

1. Split the title at ` — ` into a main title and a subtitle.
2. Pick the title size from its length and the height left.
3. Glue Japanese and Chinese words together with `Intl.Segmenter`, so no word breaks in the middle.
4. Load only the font subsets the card's text needs, each under its own name.
5. Shrink the cover into a tile with `sharp`, and build the `{ type, props }` layout with the title and seal.
6. Hand the layout to Satori for an SVG, then to resvg for a PNG.

```ts title="src/lib/og-card.ts"
async function renderOgCard(entry: BlogEntry, locale: Locale): Promise<Buffer> {
  const { title, thumbnail } = entry.data;
  // 1. Split "Main — subtitle"
  const [rawMain, ...rest] = title.split(/\s+—\s+/);
  const rawSub = rest.length ? rest.join(" — ") : undefined;
  // 2. Pick the title size
  const { size, maxLines } = fitTitle(rawMain, rawSub, locale);
  // 3. Glue CJK words
  const main = glue(rawMain, locale);
  const sub = rawSub && glue(rawSub, locale);
  // 4. Load only the subsets the text needs
  const fonts = [...fontsFor(locale, 700, main + brand + "…" + WJ) /* … */];
  // 5. Build the layout, cover tile included
  const tree = el("div", { /* … */ }, [/* seal, await thumbnailTile(thumbnail), main, sub */]);
  // 6. Lay out to SVG, then rasterise to PNG
  const svg = await satori(tree, { width: W, height: H, fonts });
  return new Resvg(svg, { fitTo: { mode: "width", value: W } }).render().asPng();
}
```

**Steps 3 and 4 are the two traps below; step 2 is the last section**.

The cover tile is read straight from `src/assets/thumbnails/<key>.png` or `.webp`, using the registry key such as `astro-generic`. `sharp` resizes it to 320×168 and converts it to PNG before it goes to Satori.

Finally, the article layout points `og:image` at the card instead of at a resized cover:

```astro title="src/layouts/ArticleLayout.astro" del={1-2} ins={3}
const ogAsset = await getImage({ src: cover.src, width: 1200, height: 630, format: "png" });
const ogImage = new URL(ogAsset.src, Astro.site).href;
const ogImage = new URL(localeUrl(locale, `/og/${slug}.png`), Astro.site).href;
```

## Two traps between Satori and a trilingual title

Both showed up on real titles from this blog, and each one renders without an error.

### Trap 1: Satori draws tofu on the Open Graph card

**Satori loads one font file per family name, so a font split across many files has to be registered under many names.** Satori can only draw characters it has been given a font for, and no system font fills in at build time. A character with no matching font is drawn as an empty box, known as tofu.

I took the fonts from Fontsource packages, `@fontsource/noto-sans-jp` and `@fontsource/noto-sans-tc`. Fontsource publishes Google Fonts and other open-source fonts as npm packages, so no font binaries enter the repository and the build fetches nothing. Noto Sans TC is there because Noto Sans JP lacks Chinese forms such as 查 and 夠, which Japanese writes differently or not at all.

Fontsource splits each weight into subsets by `unicode-range`, the range of characters each file covers: about 125 files for Noto Sans JP Bold, each shipped as both WOFF and WOFF2. Satori reads TTF, OTF and WOFF but not WOFF2.

> **HEADS UP**
>
> **Why fonts come as weights and subsets**
>
> A weight is a thickness, such as Bold (700), with its own files; a subset is one weight cut up by character range. A Japanese font has over ten thousand glyphs, several MB as one file, so browsers fetch only the subsets a page uses. Satori picks none, so `og-card.ts` does it.

The WOFF files come to about 5.2MB per weight, too much to load for every card, so `og-card.ts` reads each weight's CSS and passes Satori only the subsets whose range covers the card's text.

*Figure — FontSubsets: Only the subsets the card's text needs reach Satori, each under its own name.*

Registering those subsets under the family name they share, `Noto Sans JP`, rendered this:

*Image: A generated card for a Japanese title where almost every character in the brand name, title and subtitle is drawn as an empty box, with only a few kanji rendered*

*Only the few characters that happened to sit in the subset Satori kept are drawn.*

Giving each subset its own name and listing every name in `fontFamily` fixes it, because Satori does fall back glyph by glyph across a family stack:

```ts title="src/lib/og-card.ts" ins={6}
return faces(pkg, family, weight)
  .filter(({ ranges }) => cps.some((cp) => ranges.some(([a, b]) => cp >= a && cp <= b)))
  .map(({ family, file }) => {
    let data = fileCache.get(file);
    if (!data) fileCache.set(file, (data = readFileSync(file)));
    return { name: `${family} ${file}`, weight, style: "normal", data };
  });
```

Finally, the card's outermost element gets every one of those names, quoted and comma-separated, as its `fontFamily`:

```ts title="src/lib/og-card.ts"
const stack = [...new Set(fonts.map((f) => `"${f.name}"`))].join(", ");
// → "Noto Sans JP …/noto-sans-jp-0-700-normal.woff", "Noto Sans JP …/noto-sans-jp-latin-700-normal.woff", …

const tree = el("div", { /* … */ fontFamily: stack }, [/* … */]);
```

### Trap 2: Satori breaks Japanese and Chinese words anywhere

**Satori treats every gap between two CJK characters as a line-break opportunity**, because it has no dictionary of Japanese or Chinese words. Qiita's card splitting 検証 across two lines, shown earlier, is the same problem. The zh-TW title below wraps inside 五次, "five times", leaving 五 at the end of one line and 次 at the start of the next:

*Image: A generated card for a Traditional Chinese title whose first line ends on the character 五 and whose second line begins with 次, splitting the word 五次*

*The break falls between 五 and 次, inside one word.*

`Intl.Segmenter`, built into browsers and Node.js, does carry dictionaries for both languages when given `granularity: "word"`. `og-card.ts` segments the title, and inside each CJK word it puts U+2060 WORD JOINER between the characters, an invisible character that forbids a break at its position:

```ts title="src/lib/og-card.ts"
const CJK = /[\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/;
const WJ = "\u2060";

function glue(text: string, locale: Locale): string {
  const seg = new Intl.Segmenter(FONT_PKG[locale].segmenter, { granularity: "word" });
  return [...seg.segment(text)]
    .map(({ segment }) => (CJK.test(segment) ? [...segment].join(WJ) : segment))
    .join("");
}
```

The same title now breaks at 是從 / 五次走錯路, between words. **The joiner is a character too, so it has to be in the text the font subsets are chosen for**; otherwise no font covers it and it renders as a box.

## Sizing the title by the height left above the safe line

**The title size is chosen from the height left above the safe line at 504px.** A bigger font wraps onto more lines, so what has to fit is the total height, lines times size.

The title block starts at 272px, under the 12px bar, the 64px padding, the 168px tile and a 28px gap, so it has 232px to the safe line. The subtitle's share comes off first, then `og-card.ts` takes the largest of 80, 72, 64, 56 and 48px whose estimated height fits what is left:

```ts title="src/lib/og-card.ts"
function fitTitle(main: string, sub: string | undefined, locale: Locale) {
  const subH = sub ? SUB_GAP + 2 * SUB_SIZE * SUB_LH : 0;
  const budget = SAFE_BOTTOM - TEXT_TOP - subH;
  const widest = longestWord(main, locale);
  const size =
    [80, 72, 64, 56, 48].find(
      (s) => lines(main, s) * s * lineHeight(s) <= budget && widest * s * 0.5 <= LINE_W,
    ) ?? 48;
  const maxLines = Math.max(1, Math.floor(budget / (size * lineHeight(size))));
  return { size, maxLines };
}
```

**The subtitle's share is always two lines**, because its `lineClamp` (the most lines shown, with an ellipsis past that) is 2. A subtitle that wraps to two lines when one was expected still fits.

The title has its own cap too: `fitTitle` works out `maxLines`, the number of lines the remaining space can hold, and sets it as the title's `lineClamp`. So **a wrong estimate ends in an ellipsis rather than in the overlay**.

The lowest text on any card the site now builds, title or subtitle, sits at y=488, still above the safe line.

The `widest` check handles the word joiner's side effect: a glued word cannot break, so a long katakana compound at 80px would run off the right edge. The size steps down until the longest word fits on one line.

## Summary

Covers on this blog are shared by topic, so they say what an article is about but not which article it is. An Open Graph card exists for sharing on social media, a different job from a cover.

Every article now gets a generated card: **the cover shrinks to a tile in the top-right corner and the title is written on the card**, so a shared link shows which article it is.

At build time, a prerendered endpoint per language prepares each card's layout and hands it to Satori and resvg. Japanese and Chinese titles need two things Satori does not do on its own: a separate font name per Fontsource subset, and word joiners from `Intl.Segmenter` to keep words whole.

The title size, separately, comes from the height left above X's title strip.

## References

- [Satori: a JSX and CSS layout to SVG, including its font and CSS support notes](https://github.com/vercel/satori)
- [resvg-js: SVG to PNG in Node.js](https://github.com/thx/resvg-js)
- [Fontsource: Noto Sans JP, self-hosted in unicode-range subsets](https://fontsource.org/fonts/noto-sans-jp)
- [MDN: Intl.Segmenter, word granularity](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Segmenter)
- [GitHub Blog: A framework for building Open Graph images](https://github.blog/open-source/git/framework-building-open-graph-images/)
- [TechCrunch: X pulls headlines from link previews, then restores them over the image on the web (January 5 update)](https://techcrunch.com/2024/01/03/x-briefly-brought-back-headlines-to-link-previews-but-theyre-gone-again/)
