All articles
03 · Search & GEO · 2026.10 · 6 min

A separate share card for every page and language, drawn with Satori

Every page on this site has two 1200×630 share cards, one in Turkish and one in English, all drawn with Satori at build time. The hard part was not the layout but the fonts: ş, ğ and İ first came out as boxes. The fix was to drop font subsets and switch to full TTF files.

01Why one card per page

At first the site had a single share image, public/og.png. Whichever page's link you shared, the chat app showed the same picture. For someone sharing an article that says very little. What I wanted was a card that describes the page itself: section label, title, short description and the cover drawing that belongs to that page on the site. Since the site is bilingual, every page needed two cards. og.png is still there; the 404 page and the Person image in the JSON-LD use it.

02Tree, SVG, PNG

Satori takes a React-like element tree and font files and produces an SVG. Writing the tree in JSX is not required; plain objects with type and props fields are enough. scripts/og.mjs builds these objects with a small h helper. Layout is done with the flexbox subset Satori supports. @resvg/resvg-js fits the resulting SVG to a width of 1200 pixels and turns it into a PNG. The loadSystemFonts: false setting keeps the build machine's fonts from leaking into the card; the card is drawn with the same fonts on every machine.

scripts/og.mjs
const h = (type, style, ...children) =>
  ({ type, props: { style, children: children.length === 1 ? children[0] : children } })

const svg = await satori(tree, { width: 1200, height: 630, fonts })
return new Resvg(svg, {
  fitTo: { mode: 'width', value: 1200 },
  font: { loadSystemFonts: false }
}).render().asPng()

The cover drawing is the same procedural shape list the site draws in the browser. The rectangles and ellipses from artOf are written into an SVG and placed on the card as a data URI through an img element. Shapes that contain text are separated and laid out by Satori on their own, so they are drawn in the site's font too. Very thin lines are kept at least about 1.2 pixels thick so they do not disappear during rasterisation.

ogEntries in scripts/seo-files.mjs decides which cards to draw: one entry per public page, per language. Each entry has a section label, title, description and cover name. If a page has no matching entry, the function throws; a page whose card was forgotten stops the build. ogKeyFor produces the file name: the home page is home, /work/yee-cms is work-yee-cms, and English cards get an en- prefix. prerender.mjs writes them under dist/og, and useSeo binds og:image and twitter:image to the same address.

03When Turkish letters became boxes

In the first attempt I took the fonts from @fontsource packages. These packages split fonts into subsets by script: basic Latin in one file, extended Latin, where letters such as ş, ğ and İ live, in another. In the browser that split makes sense, because the browser only downloads the file it needs. Satori, however, needs every file handed to it as a separate font. I registered the extended subsets under separate family names and expected the missing letters to fall through to them. They did not: ş, ğ and İ came out on the cards as boxes or in the wrong face. The family names I gave the subsets were not parsed the way I expected either. The comment in og.mjs sums it up: Satori does not fall back between families reliably.

The fix was to stop dealing with subsets. The @expo-google-fonts packages on npm ship Google Fonts families as full-charset TTF files. With one complete file per weight, no fallback is needed. These packages are only in devDependencies; they are never sent to the browser.

scripts/og.mjs
async function ttf(pkg, file) {
  const dir = path.dirname(require.resolve(`${pkg}/package.json`))
  return fs.readFile(path.join(dir, file))
}

{ name: 'Syne', weight: 800, style: 'normal',
  data: await ttf('@expo-google-fonts/syne', '800ExtraBold/Syne_800ExtraBold.ttf') }
Splitting a font into pieces saves bytes in a browser and causes bugs in a card generator.

04A small patch for g and ğ

On the site, the g and ğ in headings come from Archivo Expanded rather than Syne; I told that story in a separate article. The cards had to show the same letters. At build time, the archivoPatch function asks Google Fonts for a stylesheet containing only these two letters. It sends Mozilla/4.0 as the User-Agent; Google gives such an old client TrueType instead of WOFF2. Satori can read TTF, OTF and WOFF, but not WOFF2. Both requests are bound to an 8 second timeout. Without a network, the function logs a warning and returns null, and the cards are drawn with Syne's g; the build does not break.

Satori has no per-glyph font selection like the browser, so displayText splits the title itself. Every word becomes its own flex item, and the runs of g and ğ inside a word go into a separate span with fontFamily: 'Archivo'. Using one flex item per word has a second benefit: a line never breaks in the middle of a word. The space between words is a columnGap of 0.24 times the font size.

05Layout limits and the check

Titles take one of four sizes based on length: 88 pixels up to 14 characters, 72 up to 22, 60 up to 36, and 50 beyond that. The description is cut at 150 characters on a word boundary and ends with an ellipsis. The text column is 610 pixels wide, with a 420 pixel square cover on the right. The bottom row holds the devilyxrd wordmark (the x in iris) and the domain taken from SITE_URL. The section label is uppercase and follows the Turkish İ rule.

The last build step, check-seo, verifies that og:image and twitter:image on every page are absolute and that the file exists. It then reads the width and height from the PNG header and checks for 1200×630. The cards can be seen during development too: the plugin in vite.config.ts draws /og/<key>.png requests on the fly with the same renderOg function and keeps the fonts in memory after the first load.

Related articles
  1. 04Syne's g: not a bug but the design, and I still replaced two lettersDesign & interaction2026.105 min
  2. 05Moving the loading screen jank to the compositorInterface2026.105 min