OG images from design tokens: satori, resvg and three traps
Build social cards from your design system's colors and fonts, without Chrome in CI or broken Cyrillic. Includes the generator and font scripts.
A Telegram or X link without a preview card looks like spam. You can draw each card
by hand, run headless Chrome in CI, or render it from the same tokens as the site: colours and fonts from design.md.
The third option takes 177 lines for the generator and 53 for font preparation. Ten runs on an M-series laptop reported a median of 42 ms per image, with a minimum of 34 and a maximum of 62. The benchmark script is included in the artifact.
How it works
Describe the card as an object tree. satori lays it out using flexbox rules and
produces SVG; resvg rasterizes that to PNG. No browser or runtime service:
everything happens during the build.
object tree → satori → SVG → resvg → PNG 1200×630
Speed is not the main benefit. The generator starts with constants copied from
design.md, defined before layout work.
The background uses the same #0b0c0e, and the artifact badge the same #ff8964,
as their counterparts on the article page.
Rendering options
| Approach | Advantage | Drawback |
|---|---|---|
| Headless Chrome (Puppeteer) | Pixel-for-pixel browser rendering | A full browser in CI and seconds per image |
satori + resvg |
42 ms per image in our run, without a browser | A subset of CSS and its own font rules |
astro-og-canvas |
Ready-made and configurable | Another layout to adapt to your tokens |
| Hand-made images | Full control | Does not scale well as the number of posts grows |
We expect more posts, and each card should use the design system, so we chose the second option.
The card belongs to the design system
A social card is another output of the design system, like a button. It needs access to the same tokens, rather than a separate design process.
Three traps
1. satori does not accept woff2
It accepts ttf, otf and woff. Modern font packages tend to offer woff2
first, so feeding that file straight to the renderer fails during parsing.
2. Same-name fonts do not provide glyph fallback
Fontsource supplies Latin and Cyrillic in separate files. Pass both under the same
name and you might expect satori to select the right file for each glyph.
It does not: one file is selected for the name, leaving the other glyphs as
NO GLYPH boxes.
A Russian title then turns into a mixture of readable text and boxes where letters or punctuation should be. We fixed this by merging the subsets during font preparation:
from fontTools.merge import Merger
from fontTools.ttLib import TTFont
for name in ["inter-latin-400-normal.woff", "inter-cyrillic-400-normal.woff"]:
font = TTFont(src / name)
font.flavor = None # woff -> ttf
font.save(tmp / name.replace(".woff", ".ttf"))
Merger().merge(ttfs).save("src/assets/fonts/og-body.ttf")
The resulting font file contains both alphabets, without missing-glyph boxes.
3. Native resvg-js caused bundling failures
@resvg/resvg-js uses a native .node binding. In our Vite build it failed with:
[UNLOADABLE_DEPENDENCY] Could not load @resvg/resvg-js-darwin-arm64/resvgjs.darwin-arm64.node
stream did not contain valid UTF-8
Neither ssr.external nor optimizeDeps.exclude resolved that build path:
the module was read while static routes were being generated.
We switched to @resvg/resvg-wasm: the same API, initialized once per process,
without the native-binding bundling problem.
let wasmReady: Promise<void> | null = null;
function ensureWasm(): Promise<void> {
if (!wasmReady) {
const wasm = fs.readFileSync('node_modules/@resvg/resvg-wasm/index_bg.wasm');
wasmReady = initWasm(wasm);
}
return wasmReady;
}
With the WASM renderer, on-demand edge rendering is an option. Our current wrapper reads files in Node during the build.
Picking a font size without measuring text
satori does not expose a text-width measurement for this step. We use a step
function based on title length instead:
function titleSize(title: string): number {
if (title.length <= 40) return 66;
if (title.length <= 65) return 54;
if (title.length <= 90) return 44;
return 38;
}
It is approximate but deterministic and avoids another rendering pass. The content schema caps titles at 120 characters; the finished card still needs a visual check for clipping.
The result
External image generation is needed only for design concepts, before layout. OG images themselves are built locally, without a network call.
src/pages/og/[id].png.ts produces an image for each published Russian post;
src/pages/og/default.png.ts supplies the home and utility-page card. English
versions use the matching routes under /en/og/. All are prerendered static files
served with a year-long cache policy.
Always look at the result. Our first build looked successful in the logs while
half the title was rendered as NO GLYPH boxes.