When Only the Japanese Turns to Tofu in Your Share Image — Fixing next/og CJK Fonts with Antigravity
In next/og's ImageResponse, the Japanese title renders as empty boxes while English looks fine. Here is the real cause (Satori cannot read woff2), a complete edge implementation that pulls a TrueType subset via Google Fonts css2?text=, and how to get Antigravity to fix it the first time.
The other day I shipped a new article on Antigravity Lab and, as usual, dropped the link into X and Threads. The share preview popped up, and I froze. The English helper text rendered cleanly, but the Japanese title — the part that actually mattered — was a row of empty boxes (□□□□).
When you run several sites as a solo developer, this "one language quietly breaks" class of bug is the worst kind. Nothing errors. The build passes. It only embarrasses you the moment something gets shared.
This article records the implementation that got rid of those boxes. The subject is next/og's ImageResponse, but the real point is one thing: when you hand an OG-image route to an agent like Antigravity, it will almost always fall into this trap. Why it falls in, and how to make it climb out.
It failed silently because no font was ever passed
I let Antigravity write the first route. The instruction was plain: "Return a 1200×630 OG image with the article title rendered large, via opengraph-image.tsx." In a few seconds the agent produced code that looked entirely reasonable — a few <div>s, the title poured in, returned through ImageResponse. With a local English title, it rendered. Japanese, however, came out as tofu.
Most people suspect font-family at this point, but the cause sits one layer earlier.
Inside ImageResponse is Satori, an engine that converts HTML/CSS into SVG. And Satori bundles no fonts of its own. If a glyph isn't present in a font you explicitly pass through the fonts array, that character is silently dropped. The Latin text appeared only because it happened to fall within the minimal shapes Satori carries internally; Japanese was "a character it never had" from the start.
The agent's code had no fonts entry at all. Because nothing errors, the agent itself concludes it "succeeded." That is precisely the boundary a human has to eyeball at least once.
Passing woff2 doesn't fix it either — the second trap
"So pass a font," you think, and you have Antigravity add fonts. Now you hit a different wall. Many agents reach for the common idiom of fetch-ing a Google Fonts woff2 and passing it along. That fails silently too.
The reason: Satori (and its rasterizer) cannot parse woff2. woff2 is a Brotli-compressed font format, and the formats Satori reads directly are TrueType (ttf), OpenType (otf), and woff. Hand it woff2 and you get either nothing drawn or, depending on the runtime, a thrown exception.
Here is the landscape in one table.
Format
Usable in Satori
Typical source
Notes
woff2
No
Browser-facing Google Fonts
Brotli-compressed. The most commonly served, yet unreadable by Satori
woff
Yes
Some CDNs
Readable but hard to source
ttf / otf
Yes
css2 fetched server-side
What we use here. Subsetting works
So the goal is clear: get a TrueType that contains only the Japanese glyphs you draw, cheaply, on the edge. The full Noto Sans JP is several MB; pulling the whole thing on every request isn't realistic. You only need the dozen-or-so characters in the title.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦The real reason CJK text becomes empty boxes in ImageResponse (Satori cannot parse woff2) plus a complete TrueType-subset implementation
✦How css2?text= fetches only the glyphs you draw — a few dozen KB on the edge — with a subset that includes your fixed labels too
✦A manual clamp that handles overflow within Satori's limits, and the verification step that makes an agent fix this correctly
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Fetch only the glyphs you need — the css2 text= subset
Google Fonts' css2 endpoint has a wonderful parameter, text=. It returns CSS for a subset containing only the characters you specify, which slashes the download dramatically.
There is one rarely-mentioned hinge here. css2 changes the font format it returns based on the requesting User-Agent. A modern browser UA gets woff2; anything else (a server-side fetch, whose UA isn't treated as woff2-capable) gets ttf. An edge-runtime fetch doesn't claim a browser UA, so if you do nothing, ttf comes back — exactly the form Satori can take. Conversely, if you "helpfully" add a browser-like UA, you get woff2 and you're back to tofu.
The helper that resolves the subset looks like this.
// Fetch a TrueType subset containing only the characters we draw.async function loadGoogleFont( family: string, weight: number, text: string,): Promise<ArrayBuffer> { // De-duplicate characters to keep text= short (css2 has a length limit). const glyphs = Array.from(new Set(Array.from(text))).join(""); const cssUrl = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family)}` + `:wght@${weight}&text=${encodeURIComponent(glyphs)}`; // ⚠️ Deliberately do NOT send a browser-like User-Agent. // Omitting it makes css2 return truetype instead of woff2. const css = await fetch(cssUrl).then((res) => res.text()); const src = css.match( /src:\s*url\((.+?)\)\s*format\('(?:opentype|truetype)'\)/, ); if (!src) { throw new Error("Could not resolve a TrueType subset URL"); } return fetch(src[1]).then((res) => res.arrayBuffer());}
What you pass to text= is every character you actually render. I tripped on this once. I subsetted only the title's characters and forgot the fixed label "Antigravity Lab," the domain string, and the ellipsis (…) my overflow logic appends. The title rendered, but the fixed labels turned to tofu instead. Keep the subset equal to "the set of characters that appear on screen," and you stay safe.
Pass the font in a form Satori can read
Once you have the subset ttf, the rest is just handing it to ImageResponse via fonts. Here's the full opengraph-image.tsx.
A small detail: every text-bearing element carries an explicit display: "flex". Satori treats a <div> as flex by default, but when a node has multiple children and you omit display, you can get warnings or broken layout, so being explicit prevents accidents. Your full-stack CSS intuition does not transfer cleanly — that's the baseline for working with Satori.
Kill overflow outside of Satori
The other practical problem in OG images is long titles overflowing. Satori does not fully support CSS like text-overflow: ellipsis, and -webkit-line-clamp may not behave as expected. Early on I decided it was sturdier to cut the string before drawing, without relying on layout.
For titles that mix Japanese and English, a naive function that crudely weights full-width versus half-width characters is plenty for production.
// Weight CJK as double-width and cut by visual width — a naive approximation.function clampByWidth(text: string, maxUnits: number): string { let width = 0; let out = ""; for (const ch of Array.from(text)) { width += /[\x20-\x7e]/.test(ch) ? 1 : 2; // half-width = 1, else 2 if (width > maxUnits) return out.trimEnd() + "…"; out += ch; } return out;}
It is not a perfect line-breaking algorithm. But an OG image is consumed in an instant; "not looking broken" matters far more than pixel-perfect placement. With that compromise, I unified the OG routes across all four of my sites onto the same implementation.
Measured: what subsetting actually changed
"Subsetting helps" means little without orders of magnitude. Here are rough numbers from my own Antigravity Lab route, drawing one image for a typical Japanese title (~30 characters). These are my own measurements and will vary by environment and title.
Metric
Full-font approach
text= subset approach
Font bytes fetched
~1.6–5 MB
~20–50 KB
Cold render on edge
Slow / timeout risk
roughly 250–450ms
Japanese rendering
Boxes, depending on format
Renders correctly
Fragility
Fails silently on UA / format
Pinned to ttf, stable
Once you're down to double-digit KB, running the css2 lookup and font fetch on every request is light enough — but two external fetches per cold render is still waste. I memoize the subset in a module-scoped Map, keyed by the drawn characters.
On top of that, an OG route changes slowly relative to article churn, so leaning on CDN caching (the opengraph-image response cache, or site-wide invalidation via DEPLOY_VERSION) cuts the external lookups even further.
Make the agent fix this correctly
That's the implementation, but the real subject is how to ask Antigravity to fix it fastest the next time it happens. What I learned was to hand the agent constraints and a means of verification together.
The ask that failed was, "The Japanese in the OG image isn't showing — fix it." With no error to anchor on, the agent guesses at causes, adds a font-family, tries another woff2, and piles on off-target edits.
The ask that worked led with three things. First, evidence of the failure: I actually showed it the tofu PNG and spelled out the symptom — "English renders, only Japanese is □." Second, the constraint behind the failure: I gave it two facts as premises rather than letting it guess — "Satori cannot read woff2" and "the subset must include every drawn character." Third, a machine check for pass/fail: save the generated PNG and verify the expected characters render, run as part of the fix.
Even a crude check pays off. Just writing the generated image to a file and having the agent confirm the size is non-zero and the bytes differ from the previous tofu version is enough to prevent "I think it's fixed." In the end, I look at it once myself and confirm the boxes are gone. An agent can draw, but the final judgment of "does this look right" is a boundary a human should keep — this incident reminded me of that again.
What to delegate and what to verify yourself. Domains like OG images — where breakage never throws — are exactly where that line earns its keep. The next time I stand up a new site, I plan to bring loadGoogleFont, clampByWidth, and the "evidence, constraint, verification" way of asking as a template from day one. Thank you for reading; I hope it shortens the detour for anyone fighting the same tofu.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.