Ordering the font array is how satori renders hanja instead of tofu boxes
A Korean saju service found that satori's HTML/CSS subset gives out at the hanja well before it gives out at Latin text. The fix was a two-face fallback array plus a module-level cache for two 2 MB TTFs.
The Engineer · Build desk

What happened
- A Korean saju reading service renders its share cards with satori through Next.js ImageResponse, and the same pipeline produces 60 Pinterest pins and Open Graph images for a few hundred dictionary pages.
- The brand font is a Korean handwriting face that covers Hangul and Latin but not the hanja a saju chart is built from, so every day pillar first rendered as tofu boxes.
- The working fix passes an ordered array, OreumSon for Hangul and Latin followed by OreumHan for hanja, with fontFamily set to 'OreumSon, OreumHan' in the element tree.
- The loader that reads those files is module-cached, because each TTF is 2 MB and the comment in og-fonts.ts records that the read otherwise happens on every request.
- Satori supports no mask-image, filter or backdrop-filter and reads no stylesheet, so the routes inline a small copy of the site's design tokens and the cards ship light-only.
Why it matters
- cost Every web font used in an image route needs a TTF twin maintained alongside the woff2 the browser gets, so the repository carries both formats and someone has to remember to regenerate both when the face changes.
- constraint Because the renderer neither reads the stylesheet nor supports masks, the image route cannot inherit the site's theme at all, which means token values are maintained in two places and the dark variant stops at the PNG boundary.
- decision Teams with a display brand face can stop auditing candidates for pan-CJK coverage and instead decide the order of a two-entry array, which is a cheaper and easily reversed choice than adopting one universal font.
- exposure An optional asset that fails to load will take down the entire link preview for a page that was otherwise fine, unless the loader is written to return null and render without it.
The mechanism is worth stating precisely, because it changes what you go shopping for. Satori walks the string and, for each character, tries the faces in the array in the order you passed them [5]. Nothing in that loop cares what your font claims to cover. It cares whether the glyph is present. The author's framing is the useful one: this is the `unicode-range` trick from CSS `@font-face`, relocated from the stylesheet into a JavaScript array [7]. So a display face covering Hangul and Latin stays first, and a second face carries only the hanja [6].
The arithmetic on the loader is where the design earns its keep. Two faces at 2 MB each is 4 MB of file reads per image [21]. The comment in `og-fonts.ts` says the loader runs per request [8], which would put the 60 Pinterest pins at 240 MB of font reads with no cache [22]. That figure only transfers if those 60 renders land on one warm instance. The cache is a module-scope variable holding a promise, assigned with `cached ??=` [9], so it lives exactly as long as the module instance does, and every cold invocation pays the 4 MB again. If your deploy target discards module state between requests, the cache buys you nothing and the real fix is a subsetted font rather than memoisation.
Then there is bundling, which is the failure that does not reproduce locally. The path has to be a literal `path.join(process.cwd(), '...')` because Vercel's output file tracing follows the literal string when deciding what to ship with the function, and a dynamically built path leaves the file absent at runtime [11]. Static analysis can only see strings that exist at build time. A route that computes its font path will pass on your laptop, where the file is simply on disk, and 500 in production.
The last edge is Yoga. Satori's layout engine requires that a `div` with more than one child declare `display: 'flex'`, and if it does not you get a runtime error rather than a silent fallback [17]. This is documented, and the author still hit it three times, always on inner wrappers such as the little row holding element, stage and seated god [18]. The standing rule now is that every `div` in an image route gets `display: 'flex'` unless it has exactly one text child, and `span` is only ever for text [19]. A layout engine that throws instead of guessing is doing you a favour, though it does not feel like one on the third occasion.
What makes the font ordering the load-bearing decision rather than a nicety is what the card is for. A day pillar is two characters, and the author calls the hanja the payload [20]. Everything else on the image degrades gracefully. The second face is carrying the two glyphs the card exists to show, which is why coverage order deserved more attention here than any of satori's missing CSS features.
What to watch
- Whether satori gains stylesheet reading or mask-image support, which would end the token mirroring these routes currently require.
- Whether the TTF/OTF-only restriction lifts, since that is what forces a second copy of every web font into the deploy.
- Whether font subsetting arrives in the ImageResponse path, which would shrink the 2 MB faces to the glyphs a card actually draws.
Clarity's read
What the record supports and how the coverage leans. The claims behind it follow.
Reality
- Evidence62
- Adoption30
- Hype gap−8
- Incentives38
- Confidence55
Claim ledger
Ranked by verification strength, evidence, and original report placement.
- [1]
The author runs a small Korean saju (Four Pillars) reading service, and every reading ends with a share card: a PNG carrying the person's day pillar in Chinese characters, a hand-drawn animal, and a one-line epithet in Korean or English.
- [2]
The same pipeline also generates 60 Pinterest pins and Open Graph images for a few hundred dictionary pages.
- [3]
All of the images are rendered with satori through Next.js's ImageResponse, with no headless browser: you write JSX and get a PNG.
- [4]
The brand font is a Korean handwriting face that covers Hangul and Latin but does not cover the hanja a saju chart is made of; rendered with only that font, every pillar came out as tofu boxes.
- [5]
Satori resolves glyphs per character across the fonts you pass, in the order you pass them.
- [6]
The fix was a fallback set ordered most specific first: OreumSon (Hangul plus Latin) then OreumHan (hanja), with fontFamily 'OreumSon, OreumHan' in the element tree, so Hangul and Latin come from the first face and hanja fall through to the second.
- [7]
The author describes the ordered font array as exactly the unicode-range trick you would do in CSS @font-face, with satori making you do it through the font array instead.
- [8]
The font loader in og-fonts.ts is module-cached, and its comment states that the TTFs are 2 MB each and that this runs per request.
- [9]
The cache is a module-scope variable typed as a promise or null, populated with the cached ??= (async () => ...) pattern that reads both TTFs in parallel.
- [10]
Satori wants TTF/OTF rather than woff2, so the project keeps TTF copies in src/assets/fonts purely for the image routes while the browser is served woff2.
- [11]
The font path must be a literal path.join(process.cwd(), '...') because Vercel's output file tracing follows the literal string and bundles the font with the function; build the path dynamically and the file is not there at runtime.
- [12]
Satori does not support mask-image, filter or backdrop-filter, and it does not read your stylesheet, so every value must be inline in the JSX.
- [13]
The image routes share none of the site's CSS and instead carry a tiny mirror of the design tokens, including paper #fdfbf3, ink #221d16 and seal #b1402b, for the light theme only.
- [14]
The cards are light-only, partly because masks are unavailable and partly because Pinterest feeds and link previews are light backgrounds anyway.
- [15]
The twelve zodiac animals are pre-rendered as PNGs with ink strokes baked in and inlined as data URIs, held in a Map cache.
- [16]
The animal loader sets null on a failed read so the pin still renders without the art, because an image route that 500s over a missing optional asset takes the whole Open Graph preview down with it.
- [17]
Satori's layout engine is Yoga, and a div with more than one child must declare display: 'flex' or you get a runtime error rather than a fallback.
- [18]
The author hit the flex requirement three times despite it being documented, on inner wrappers such as the row holding element, stage and seated god.
- [19]
The author's rule is now that every div in an image route gets display: 'flex' unless it has exactly one text child, and span is for text only.
- [20]
A day pillar is two characters, and the author calls the hanja the payload of the card.
- [21]
Two font faces at 2 MB each means about 4 MB of font file reads per image request when the loader is not cached.
- [22]
Rendering the 60 Pinterest pins with an uncached loader would read about 240 MB of font data; with a warm module cache on a single instance it is 4 MB.
- [23]
Satori is a very specific subset of HTML/CSS, and CJK text hits the edges of that subset faster than Latin text does.
Get this ledger inside ChatGPT. Ask about any of our stories and the answer brings its figure, each claim’s verdict and the sources. Free, no account.
Sources
1 independent publisher whose own reporting we read for this story.
- dev.toRendering CJK-heavy share cards with satori (and the three things that broke)
1 article · August 29, 2026
Topics and entities
Follow any of these and your For You feed starts watching them — no settings page required.