I run a small Korean saju (Four Pillars) reading service. Every reading ends with a share card — a PNG with the person's day pillar in Chinese characters, a hand-drawn animal, and a one-line epithet in Korean or English. We also generate 60 Pinterest pins and Open Graph images for a few hundred dictionary pages the same way.
All of it is rendered with satori through Next.js's ImageResponse. Satori is wonderful: you write JSX, you get a PNG, no headless browser. It is also a very specific subset of HTML/CSS, and CJK text hits the edges of that subset faster than Latin text does. Here is what broke, in the order it broke.
1. Your font does not have the glyph, and satori will not tell you
The brand font is a Korean handwriting face. It covers Hangul and Latin. It does not cover the hanja (Chinese characters) that a saju chart is made of — 甲子, 丙寅, and so on. Rendered with only that font, every pillar came out as tofu boxes.
Satori resolves glyphs per character across the fonts you pass, in order. So the fix is not "find one font with everything" — it is "pass a fallback set, most specific first":
// og-fonts.ts — module-cached; the TTFs are 2 MB each and this runs per request
let cached: Promise<{ son: Buffer; han: Buffer }> | null = null;
function loadHandFonts() {
cached ??= (async () => {
const [son, han] = await Promise.all([
fs.readFile(path.join(process.cwd(), 'src/assets/fonts/OreumSon.ttf')), // Hangul + Latin
fs.readFile(path.join(process.cwd(), 'src/assets/fonts/OreumHan.ttf')), // hanja
]);
return { son, han };
})();
return cached;
}
export async function handFontOptions() {
const { son, han } = await loadHandFonts();
return [
{ name: 'OreumSon', data: son, style: 'normal' as const, weight: 400 as const },
{ name: 'OreumHan', data: han, style: 'normal' as const, weight: 400 as const },
];
}
and in the element tree, fontFamily: 'OreumSon, OreumHan'. Hangul and Latin come from the first face, hanja fall through to the second. This is exactly the unicode-range trick you would do in CSS @font-face — satori just makes you do it with the font array.
Two things worth knowing:
-
Satori wants TTF/OTF, not woff2. Our web fonts are woff2 for the browser; we keep TTF copies in
src/assets/fontsjust for image routes. -
Use a literal
path.join(process.cwd(), '...'). 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.
2. There is no mask-image, and there is no CSS file
Our light/dark theme in the browser is done with icon masks: an SVG alpha mask, colored with background-color, so one asset serves both themes. Satori does not support mask-image (or filter, or backdrop-filter). It also does not read your stylesheet — every value must be inline in the JSX.
So the image routes do not share the site's CSS at all. They carry a tiny token mirror instead:
// tokens mirrored from oreum.css — light theme only
const T = {
paper: '#fdfbf3',
ink: '#221d16',
seal: '#b1402b',
faded: '#8a8166',
muted: 'rgba(34, 29, 22, 0.5)',
} as const;
And the cards are light-only. Partly because masks are unavailable, partly because the places these images land (Pinterest feeds, link previews) are light backgrounds anyway. Accept the constraint; do not fight it with tricks.
For the animal art, we pre-render each of the twelve zodiac animals as a PNG with ink strokes baked in and inline it as a data URI:
const artCache = new Map<string, string | null>();
async function loadAnimal(file: string): Promise<string | null> {
if (!artCache.has(file)) {
try {
const buf = await fs.readFile(path.join(process.cwd(), 'src/assets/card-animals', file));
artCache.set(file, `data:image/png;base64,${buf.toString('base64')}`);
} catch {
artCache.set(file, null); // the pin must still render without the art
}
}
return artCache.get(file) ?? null;
}
Note the null branch. An image route that 500s because an optional asset is missing takes your whole Open Graph preview down with it.
3. Every multi-child div needs display: flex
This one is documented, and I still hit it three times. Satori's layout engine is Yoga; a <div> with more than one child must declare display: 'flex' or you get a runtime error, not a fallback. It is easy to forget on the inner wrappers — the outer card is obviously flex, the little row that holds "element · stage · seated god" is not obviously anything, and that is the one that throws.
My rule now: every div in an image route gets display: 'flex' unless it has exactly one text child. span is for text only.
What the CJK detail actually changes
The hanja are the payload. A day pillar is two characters — showing them big, in the correct face, is the entire point of the card. If they render as boxes the card is decoration; if they render in a calligraphic face next to a handwriting Latin face, it reads as one object. The font fallback array is a five-line change and it is the difference between the two.
Sizes, for reference: the 2:3 Pinterest pin is 1000×1500 with the pillar at 116px and the hanja block at 210px; the 1200×630 OG card puts the two characters at 150px on the left and the title at 72px. Both come out around 50–160 KB as PNG.
The rendering code runs on a deterministic table — every one of the 60 pins is computed from the same data the dictionary pages use, so the pin can't disagree with the page. That part is not a satori trick; it is just refusing to hand-write sixty descriptions.
If you want to see the output: the 60 day-pillar pins live at ioreum.com/en/day-master, and the calendar math underneath is open source as k-saju (MIT, TypeScript).
Top comments (0)