2 min read
Why your OG image crashes with variable fonts
Satori won't load WOFF2 or variable fonts with a multi-axis fvar table. The fix wasn't fighting the config — it was understanding which font format the renderer actually expects.
- astro
- og-images
- satori
- fonts
I wanted Open Graph images that were unique per project and language, generated on the server instead of hand-designed. The usual recipe: Satori turns an HTML/CSS-like tree into SVG, and resvg rasterizes that SVG into a PNG. On paper, an afternoon of work. In practice, two crashes back to back before I saw a single pixel.
Crash one: the native binding that breaks the dev server
@resvg/resvg-js isn’t pure JavaScript — it ships a compiled .node binary.
Vite’s dependency optimizer (esbuild) tries to fold it into its pre-bundle and
falls over, because esbuild can’t package a native addon. The production build
passed fine, but pnpm dev wouldn’t even boot the endpoint.
The fix was telling Vite to leave it alone: mark the package in
optimizeDeps.exclude and ssr.external in astro.config.mjs. Once it’s out
of the pre-bundle, the binding loads through native require and behaves
identically in dev and production.
Crash two: the font the browser loves and Satori rejects
The site already served Open Sans as a variable font in WOFF2. I reused that same file for Satori and got a cryptic error about an unknown OpenType signature. The reason is obvious once you see it: WOFF2 compresses the font tables with Brotli, and the OpenType parser Satori uses doesn’t decompress Brotli. The browser does; Satori doesn’t. They’re two different pipelines.
I switched to a TTF. Another crash, this time over the fvar table: a variable
font stores every weight in one file with interpolable axes, and Satori doesn’t
resolve named instances. It doesn’t know what to do with “a weight somewhere
between 300 and 800” — it wants one number and one concrete glyph table.
The fix: static instances per weight
The answer wasn’t a magic flag, it was handing the renderer exactly what it
expects. I exported two static instances from the variable font —
OpenSans-Regular (400) and OpenSans-Bold (700) — as plain TTFs, and
registered each one with its explicit weight when instantiating Satori. No
fvar, no Brotli, no ambiguity.
What I took away
The runtime font pipeline is not the browser’s. The browser is generous: WOFF2, variable fonts, it takes all of it. A renderer like Satori is literal and wants static TTF, one file per weight. When a low-level library “doesn’t support” a format, it’s rarely a bug on its end — it’s a hint about which level of abstraction it expected to receive.