OG Images with Satori and resvg-js in Bun: Server and CLI
Every link to this blog and to every vibe on vibe.oakoliver.com unfurls with a card made the same way: Satori lays out a small tree of elements and writes an SVG, and resvg-js turns the SVG into a PNG. No headless browser, no image service. Both run on Bun.
This article is the practical version of that setup: a standalone CLI script, a cached HTTP route, how fonts work, what the two steps cost, and the pitfalls we found in our own code while writing this. If you want the build-time variant that generates hundreds of cards during a static build, read 480 Unique OG Images at Build Time.
Satori does the layout and turns text into vector paths; resvg only has to paint them. Cache the PNG, load your fonts from disk, and check what each upgrade changes.
Every program below was run on Bun 1.4.4 with satori 0.63.2 and @resvg/resvg-js 2.6.2. The CLI was also run on Node 26.8 and against satori 0.19.1, the version our two sites use.
I – Two Steps: Tree to SVG, SVG to PNG

That card is the output of the CLI in section III. It was made in two steps.
Satori takes a tree of elements with inline styles, a width, a height and some fonts. It computes a flexbox layout, shapes the text with the fonts you gave it, and returns an SVG string. Each run of text becomes a <path>: the SVG for the card above contains no <text> element at all: three <path>s, a few rectangles and masks, and the gradient. That is why Satori needs the font bytes, and why the SVG looks the same on every machine.
resvg is a Rust SVG renderer; @resvg/resvg-js is its Node binding, a native addon that Bun loads like any other. It rasterises the SVG into a PNG. Because the text is already paths, resvg does not need any fonts to draw it. Remember that; it matters for speed in section VI.
mkdir og && cd og && bun init -y
bun add satori @resvg/resvg-js
II – Fonts: Bytes, Not Names
Satori has no font of its own and no access to system fonts. You pass the raw bytes of every font file you use, one entry per weight and style:
{ name: 'Inter', data: await readFile('fonts/latin-700-normal.ttf'), weight: 700, style: 'normal' }
With no fonts at all, it refuses to render: Error: No fonts are loaded. At least one font is required to calculate the layout.
Which formats work depends on the version, and our own notes were out of date. Our platform docs said "Satori only supports TTF/OTF, not WOFF2". I tested the same Inter weight in three formats, downloaded from Fontsource:
| Font file | satori 0.19.1 | satori 0.63.2 |
|---|---|---|
.ttf |
renders | renders |
.woff |
renders | renders |
.woff2 |
Error: Unsupported OpenType signature wOF2 |
renders |
WOFF has always worked. WOFF2 support arrived in satori 0.55.0 on 13 October 2026. If you are on an older version and see wOF2 in an error, that is the reason: download the .ttf or .woff instead. Fontsource serves all of them from the same path, for example https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-700-normal.ttf.
For a script or a server, download the files once and keep them next to the code. Section VIII explains why fetching them from a CDN at request time, which both our sites do, is a bad idea.
mkdir fonts && cd fonts
curl -fLO https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-400-normal.ttf
curl -fLO https://cdn.jsdelivr.net/fontsource/fonts/inter@latest/latin-700-normal.ttf
III – A Standalone CLI
Satori is usually shown inside a framework route. It does not need one. Here is a complete command-line tool, one file, that writes a PNG and optionally the SVG next to it:
#!/usr/bin/env bun
// og.ts: render a 1200x630 Open Graph card to PNG with Satori and resvg.
// bun og.ts (or node og.ts) "Title" [--subtitle "..."] [--out card.png] [--svg]
import { readFile, writeFile } from 'node:fs/promises';
import { parseArgs } from 'node:util';
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
const { values, positionals } = parseArgs({
args: process.argv.slice(2),
allowPositionals: true,
options: {
subtitle: { type: 'string', default: 'engineering.oakoliver.com' },
out: { type: 'string', default: 'og.png' },
svg: { type: 'boolean', default: false },
},
});
const title = positionals.join(' ');
if (!title) {
console.error('usage: bun og.ts "Title" [--subtitle text] [--out file.png] [--svg]');
process.exit(1);
}
// Satori takes a React element, or any object shaped like one: { type, props }.
type Node = { type: string; props: Record<string, unknown> };
const h = (type: string, style: Record<string, unknown>, ...children: (Node | string)[]): Node => ({
type,
props: { style, children: children.length === 1 ? children[0] : children },
});
const card = h(
'div',
{
width: '100%',
height: '100%',
boxSizing: 'border-box', // satori >= 0.42 defaults to content-box, like CSS
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: 64,
background: 'linear-gradient(135deg, #050a0f 0%, #0f172a 55%, #064e3b 100%)',
color: '#f5e0b5',
fontFamily: 'Inter',
},
h('div', { fontSize: 22, fontWeight: 700, letterSpacing: 4, color: '#d4af37' }, 'OAK OLIVER · ENGINEERING'),
h('div', { fontSize: 64, fontWeight: 700, lineHeight: 1.15 }, title),
h('div', { fontSize: 26, color: '#10b981' }, values.subtitle!),
);
// Fonts: raw TTF/OTF/WOFF bytes (WOFF2 only from satori 0.55). One entry per weight you use.
const font = (file: string) => readFile(new URL(`./fonts/${file}`, import.meta.url));
const fonts = [
{ name: 'Inter', data: await font('latin-400-normal.ttf'), weight: 400 as const, style: 'normal' as const },
{ name: 'Inter', data: await font('latin-700-normal.ttf'), weight: 700 as const, style: 'normal' as const },
];
const t0 = performance.now();
const svg = await satori(card as any, { width: 1200, height: 630, fonts });
const t1 = performance.now();
// Satori already turned every glyph into a <path>, so resvg needs no fonts of its own.
const png = new Resvg(svg, { font: { loadSystemFonts: false } }).render().asPng();
const t2 = performance.now();
await writeFile(values.out!, png);
if (values.svg) await writeFile(values.out!.replace(/\.png$/, '') + '.svg', svg);
console.log(
`${values.out}: ${png.length} bytes (satori ${(t1 - t0).toFixed(0)} ms, resvg ${(t2 - t1).toFixed(0)} ms)`,
);
It uses no React. Satori accepts any object shaped like a React element, { type, props: { style, children } }, and the small h() helper builds those.
$ bun og.ts "OG Images with Satori and resvg in Bun" --out card.png
card.png: 141803 bytes (satori 23 ms, resvg 12 ms)
$ node og.ts "OG Images with Satori and resvg in Bun" --out card.png
card.png: 141803 bytes (satori 29 ms, resvg 14 ms)
It only uses node:fs and node:util, so it runs unchanged on Node 26 too, which strips the TypeScript types itself. Bun and Node produced byte-for-byte identical PNGs. With the shebang line, chmod +x og.ts && ./og.ts "Title" also works.
The times are for one cold run of the script, including Satori's own start-up. Inside a long-running process both steps get much faster; section V has those numbers.
The same card in JSX
If you prefer JSX, add React and write a .tsx file; Bun compiles it without configuration. Satori calls function components itself:
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
function Card({ title }: { title: string }) {
return (
<div style={{ width: '100%', height: '100%', boxSizing: 'border-box', display: 'flex', alignItems: 'center',
padding: 64, background: '#0f172a', color: '#f5e0b5', fontSize: 64, fontWeight: 700 }}>
{title}
</div>
);
}
const fonts = [{ name: 'Inter', data: await Bun.file('fonts/latin-700-normal.ttf').arrayBuffer(), weight: 700 as const }];
const svg = await satori(<Card title="Written in JSX" />, { width: 1200, height: 630, fonts });
await Bun.write('jsx.png', new Resvg(svg, { font: { loadSystemFonts: false } }).render().asPng());
console.log('jsx.png', (await Bun.file('jsx.png').arrayBuffer()).byteLength, 'bytes');
$ bun add react && bun jsx.tsx
jsx.png 11421 bytes
IV – Layout Rules That Bite
Satori implements a subset of CSS, and the subset has changed a lot recently: 0.19.1 was released in January 2026, 0.20.0 in March, and 0.63.2 on 20 October 2026. Three rules from our own cards:
1. display: flex on anything with more than one child (on 0.19). Satori 0.19.1 throws Expected <div> to have explicit "display: flex", "display: contents", or "display: none" if it has more than one child node. Satori 0.63.2 lays the same tree out without complaint. If you support both, set display: 'flex' on every container.
2. box-sizing changed in 0.42. Up to satori 0.41.0, a root element with width: '100%' and padding: 100 stayed 1200 pixels wide, as if every box were border-box. From 0.42.0 the default is content-box, as in CSS: the same element becomes 1400 × 830, and the right and bottom padding fall outside the 1200 × 630 picture. I found the version by running the same tree on every release from 0.38 to 0.44; the 0.42.0 release notes only mention colour changes. The fix is one line on the root, which is why it is in the CLI above:
boxSizing: 'border-box',
3. Some properties are ignored, loudly. Our blog's card uses zIndex: 1 on three elements. Satori 0.19.1 does not support it and prints `z-index` is currently not supported. for each one on every render. Elements are painted in tree order, so put the background first and drop zIndex.
Emoji need a loader. A title like "Ship it 🚀" renders the font's "missing glyph" box in place of the rocket, with no warning, because Inter has no emoji. Satori calls loadAdditionalAsset(code, segment) for text the fonts cannot draw; for code === 'emoji' return an SVG data URL for that emoji, and for other languages return extra fonts.
V – Serving Cards From Bun, With a Cache
Most cards are requested by crawlers: Slack, Discord, X, LinkedIn, iMessage and others each fetch the image when a link is shared. The same card is fetched again and again, so the expensive part should run once.
// server.ts: /og/<slug>.png rendered on first request, then served from memory.
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
const posts: Record<string, string> = {
'hello-world': 'Hello, World',
'satori-resvg-bun': 'OG Images with Satori and resvg in Bun',
};
// Load fonts once, at startup, from disk: no CDN on the request path.
const fonts = await Promise.all(
[400, 700].map(async (weight) => ({
name: 'Inter',
data: await Bun.file(`./fonts/latin-${weight}-normal.ttf`).arrayBuffer(),
weight: weight as 400 | 700,
style: 'normal' as const,
})),
);
async function renderCard(title: string): Promise<Uint8Array> {
const svg = await satori(
{
type: 'div',
props: {
style: {
width: '100%', height: '100%', boxSizing: 'border-box', display: 'flex', alignItems: 'center', padding: 64,
background: '#0f172a', color: '#f5e0b5', fontFamily: 'Inter', fontSize: 64, fontWeight: 700,
},
children: title,
},
} as any,
{ width: 1200, height: 630, fonts },
);
return new Resvg(svg, { font: { loadSystemFonts: false } }).render().asPng();
}
const MAX = 500; // a Map keeps insertion order, which is enough for a small LRU
const cache = new Map<string, Uint8Array>();
Bun.serve({
port: 3001,
async fetch(req) {
const m = new URL(req.url).pathname.match(/^\/og\/([a-z0-9-]+)\.png$/);
const title = m && posts[m[1]!];
if (!title) return new Response('Not found', { status: 404 }); // never render unknown slugs
const key = `${m![1]}:${title}`; // include whatever changes the picture
let png = cache.get(key);
const hit = png !== undefined;
if (png) cache.delete(key); // re-insert below: most recent goes last
png ??= await renderCard(title);
cache.set(key, png);
if (cache.size > MAX) cache.delete(cache.keys().next().value!);
return new Response(png, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=86400',
'X-Cache': hit ? 'hit' : 'miss',
},
});
},
});
console.log('http://localhost:3001/og/satori-resvg-bun.png');
$ curl -s -o /dev/null -w "%{time_total}s\n" localhost:3001/og/satori-resvg-bun.png # X-Cache: miss
0.026612s
$ curl -s -o /dev/null -w "%{time_total}s\n" localhost:3001/og/satori-resvg-bun.png # X-Cache: hit
0.000377s
$ curl -s -o /dev/null -w "%{http_code}\n" localhost:3001/og/nope.png
404
The first request rendered in 27 ms, later ones were served from memory in under half a millisecond, and an unknown slug got a 404 without rendering anything. In a warm process, a simple one-line card takes Satori about 1 ms and resvg about 12 ms.
Three details are deliberate:
- The key contains the title, not just the slug. Rename a post and the old card is not served again.
- Unknown slugs return 404. Rendering a card for any slug lets anyone put any text on an image served from your domain. Our blog route does the same: it validates the slug with
/^[a-z0-9-]+$/, returns 404 for unknown articles, and lets an article ship its ownpublic/articles/<slug>/og.pngthat wins over the generated one. - Fonts are read once at start-up, from disk.
VI – What It Costs in Production
I measured our blog's /og/<slug>.png route in production on 28 October 2026. An article page answered with a time-to-first-byte of about 0.21 s. The card for the same article took about 0.76 s and weighed 273 KB. The route has no in-process cache, so every request that misses the HTTP caches renders the card again: about half a second of server time each.
Locally the same generateOGImage function took about 270 ms per card on an Apple M5 Max. Almost none of that is Satori, which takes about 8 ms. The rest is resvg, for two reasons:
- System fonts. By default resvg-js loads every font installed on the machine before rendering. Satori already drew the text as paths, so none of them is used.
new Resvg(svg, { font: { loadSystemFonts: false } })skips the scan. On the simple card it cut resvg from 78 ms to 12 ms per image on my Mac; a slim Alpine container has far fewer fonts, so the saving there is smaller. - Three overlapping radial gradients. Our card has a decorative full-size layer with three
radial-gradients. Removing that one layer and the system fonts took the function from about 270 ms to about 20 ms. The gradients also make the PNG large, because noisy gradients compress poorly.
So the cheapest render is a flat or linear-gradient background, loadSystemFonts: false, and a cache in front. The most expensive is what our blog runs today.
VII – resvg-js or resvg-wasm
@resvg/resvg-js is a native addon with a prebuilt binary per platform: macOS, Windows, and Linux for both glibc and musl, so it installs in an oven/bun:1-alpine image without a compiler. If you cannot load native code (an edge runtime, a browser), @resvg/resvg-wasm is the same renderer compiled to WebAssembly:
import { initWasm, Resvg } from '@resvg/resvg-wasm';
await initWasm(Bun.file('node_modules/@resvg/resvg-wasm/index_bg.wasm').arrayBuffer());
const png = new Resvg(svg).render().asPng();
Rendering the card from section I twenty times in a warm process:
| Package | Per image | Output |
|---|---|---|
@resvg/resvg-js 2.6.2 (native, loadSystemFonts: false) |
12.3 ms | 123,317 bytes |
@resvg/resvg-wasm 2.6.2 |
44.1 ms | 123,317 bytes, same hash |
The two produced byte-identical PNGs. On a server where native addons work, the native package is about 3.5 times faster.
VIII – Pitfalls From Our Own Code
Writing this article meant re-reading both of our routes. These are the things I would change, in order of how much they matter:
- Cache the PNG, not only the fonts. Both routes keep the fonts in a module variable and send
Cache-Control: public, max-age=3600, but render on every request that reaches them. - Load fonts from disk. Both fetch Inter from
cdn.jsdelivr.net/fontsource/fonts/inter@latest/...on the first render after start. If jsDelivr is slow, the first crawler waits; if it fails, the route answers 500. And@latestmeans a new Inter release can change your cards without a commit. - Don't vary the image by User-Agent behind a shared cache. Both routes pick a size per crawler (1200 × 628 for X, 1200 × 627 for LinkedIn, 1200 × 630 for the rest) on the same URL, with
publiccaching and noVary: User-Agent. A CDN may hand one crawler's size to another, and the pages'og:image:heightsays 630 in every case. One size for everyone is simpler and correct. - Watch caret ranges on 0.x.
"satori": "^0.19.1"allows 0.19.x only, never 0.20, sobun updatewill not bring WOFF2 support, and it will not bring thebox-sizingchange either. Upgrading is a deliberate step: render your cards before and after and compare them. - Let the layout truncate, not
slice(). Our routes cut titles at 45 and 95 characters, but a character count says little about width: "WWW" is far wider than "iii". On 0.19.1,display: 'block', lineClamp: 2on the title element lets Satori fill exactly two lines at the real width and end with an ellipsis. On 0.63.2 the same style overflowed the card's right edge in my test, so check it after an upgrade.
IX – What to Take Away
Satori and resvg split the work cleanly. Satori needs your fonts and does the layout; resvg only paints, so give it loadSystemFonts: false.
A CLI is twenty lines away. Satori needs no framework and no React: an object tree, font bytes and a size are enough, on Bun or Node.
Pin and test the version. Satori went from 0.20 to 0.63 in seven months, and one of those releases changed the box model without saying so.
Measure the card you actually ship. Ours spent most of its time painting a decoration nobody notices at thumbnail size.
Requirements
- Runtime. Bun 1.4.4 for everything; the CLI also on Node 26.8.1. Timings are from an Apple M5 Max unless they say production.
- Packages.
satori0.63.2 (and 0.19.1 for the comparisons),@resvg/resvg-js2.6.2,@resvg/resvg-wasm2.6.2,react19 for the JSX example only. - Fonts. Inter from Fontsource, latin subset, weights 400 and 700.
- Related. Why We Mass-Replaced Node.js With Bun in Production covers the runtime these routes run on, and We Use Subdomains as Security Boundaries explains the vibe platform whose cards are rendered by the second route.