How to render a branded OG / share card with Satori¶
Diataxis type: how-to. Goal-oriented steps for generating a 1200×630 branded PNG from data, server-side. Assumes you know why (see the explanation) and the technique facts (see the reference). Validated by the 2026-05-27 spike.
This guide shows you how to turn structured data (a Lugar place, a chat answer, an
itinerary) into a branded social/OG image without screenshotting the DOM — so it
works over our glass UI and stays fast.
Install¶
Use @resvg/resvg-js (native binding) on a Node server. If you instead render at the
edge, use @vercel/og (it bundles satori + resvg-WASM under the ~500 KB cap) and skip
the resvg step below.
1. Load fonts (mandatory)¶
Satori does not read system fonts. Supply TTF/OTF/WOFF buffers (not WOFF2). Ship the brand serif (Fraunces) and a sans with the renderer and load them once:
import { readFileSync } from 'node:fs';
const fonts = [
{ name: 'Brand Serif', data: readFileSync('fonts/Fraunces.ttf'), weight: 400, style: 'normal' },
{ name: 'Brand Sans', data: readFileSync('fonts/Inter.ttf'), weight: 400, style: 'normal' },
];
2. Build the card as a flexbox element tree¶
You do not need JSX/React — Satori accepts the React-element object shape
({ type, props: { style, children } }). Set display: flex on every element with
more than one child, or Satori throws. A helper that injects it by default keeps you
safe:
const el = (type, style, children) => ({ type, props: { style: { display: 'flex', ...style }, children } });
Lay out bottom-aligned text over a background. There is no z-index, so paint order is
source order: background first, overlay second, content last.
const W = 1200, H = 630;
const card = (lugar, bg) => el('div',
{ width: W, height: H, position: 'relative', fontFamily: 'Brand Sans', overflow: 'hidden' },
[
bg, // gradient div or <img> (see step 3)
el('div', { position: 'absolute', top: 0, left: 0, width: W, height: H, backgroundColor: 'rgba(8,20,20,0.45)' }, []),
el('div', { position: 'relative', flexDirection: 'column', justifyContent: 'space-between', width: W, height: H, padding: 64 }, [
el('div', { fontFamily: 'Brand Serif', fontSize: 30, color: '#f4efe6', letterSpacing: 3 }, 'PORTUGAL ODYSSEY'),
el('div', { flexDirection: 'column' }, [
el('div', { fontSize: 24, color: '#e9c46a', letterSpacing: 4 }, lugar.region.toUpperCase()),
el('div', { fontFamily: 'Brand Serif', fontSize: 92, color: '#fff', lineHeight: 1 }, lugar.locationLabel),
el('div', { fontSize: 30, color: '#efeae0', marginTop: 18, maxWidth: 780, lineHeight: 1.35 }, lugar.caption),
]),
]),
],
);
3. Provide the background¶
For a photo background, pre-fetch the image and inline it as a data URI (more reliable than letting Satori fetch it, and it sidesteps CORS):
const res = await fetch(lugar.photoUrl);
const dataUri = `data:${res.headers.get('content-type')};base64,${Buffer.from(await res.arrayBuffer()).toString('base64')}`;
const bg = { type: 'img', props: { src: dataUri, style: { position: 'absolute', top: 0, left: 0, width: W, height: H, objectFit: 'cover' } } };
For a gradient background (no photo), use a div:
const bg = el('div', { position: 'absolute', top: 0, left: 0, width: W, height: H, backgroundImage: 'linear-gradient(135deg, #0f3d3e, #7a4a2b)' }, []);
4. Render to PNG¶
import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';
const svg = await satori(card(lugar, bg), { width: W, height: H, fonts });
const png = new Resvg(svg, { fitTo: { mode: 'width', value: W } }).render().asPng(); // Buffer
5. Serve it where crawlers can see it¶
These frontends are Vite SPAs, so the runtime SEO.tsx meta-updater is invisible to
crawlers and social unfurlers (WhatsApp, iMessage, Facebook, Slack…) — they fetch the
raw HTML and never run the JS. Two parts:
(a) The render route. The api-gateway serves the cards as real PNGs at
/public/og/{landing,lugar/:slug,partner/:slug}.png (services/api-gateway/src/routes/og.routes.ts,
renderer in services/api-gateway/src/services/card-renderer.ts). It has an anon-cache
pattern to reuse.
(b) Make per-page og:image crawler-visible. A static default is hardcoded in each
index.html (the landing card). For dynamic per-path OG (e.g. each partner), rewrite
the served HTML at the edge — an nginx regex location + sub_filter in the frontend's
Dockerfile entrypoint. This is what shipped for public-fo /partner/<slug> (Riff #218,
commit efab1fd):
location ~ ^/partner/(?<og_slug>[^/]+) {
root /usr/share/nginx/html;
try_files /index.html =404;
sub_filter_once off; # rewrites BOTH og:image + twitter:image
sub_filter '/public/og/landing.png"' '/public/og/partner/${og_slug}.png"';
}
Match the path tail only (not the host) so it stays env-agnostic across dev/qual/prod;
sub_filter_once off to hit both tags; the named capture (?<og_slug>[^/]+) stops at the
first /. SPA boot is unaffected — only the initial <head> differs. Verify with
curl <page-url> | grep og:image (NOT devtools — a crawler never runs the JS). See the
project memory feedback_spa_og_needs_edge_rewrite for the full pattern + gotchas.
If the text overflows or the layout breaks¶
- Multi-child element with no
display: flex→ add it (Satori throws otherwise). - Garbled glyphs / missing accents → the font lacks those glyphs; use a font with full Latin coverage (Fraunces/Inter do).
- Element stacking wrong → reorder children (no
z-index; later paints on top). - Layout not as expected → remember it is flexbox-only; no
grid.