Skip to content

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

npm install satori @resvg/resvg-js

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.