Skip to content

Build Not yet confirmed elsewhere1 publisher 3 min read Published

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

How we use AI Send a correction

Illustration accompanying Ordering the font array is how satori renders hanja instead of tofu boxes
Generated illustration

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
Why these scores

Claim ledger

Ranked by verification strength, evidence, and original report placement.

  1. [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.

    Reported SupportedView cited source
  2. [2]

    The same pipeline also generates 60 Pinterest pins and Open Graph images for a few hundred dictionary pages.

    Reported SupportedView cited source
  3. [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.

    Reported SupportedView cited source

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.

Add to ChatGPTAdd to Claude

Sources

1 independent publisher whose own reporting we read for this story.

  1. dev.to

    1 article · August 29, 2026

    Rendering CJK-heavy share cards with satori (and the three things that broke)

Share your take

Let Clarity write the post for you.

Signed-in readers get a short post drafted on this story in the register they choose — narrative, analytical, or a direct position — editable to the last word before it goes anywhere. The share buttons at the top of this story work without an account.

Topics and entities

Follow any of these and your For You feed starts watching them — no settings page required.

Topics

  • Generated share and Open Graph imagesFollow
  • CJK font coverage and fallbackFollow
  • Serverless render-path cost and failure modesFollow
Loading related stories