Postcards from a Satori OG factory
How we built a per-route OG card system for catsoupmedia.com — Satori + resvg, hash-deterministic mood cats, two CTA pools per locale, and the JS signed-int bug we hit along the way.
When you share a Catsoup Media post, the preview card you see — purple panel, saffron disc, mood-randomized cat, rotating CTA — is rendered by a small factory we wrote in an afternoon. This post is the factory tour, the bug we caused, and a few opinions on Satori in production.
The shape of the thing
Every OG card on catsoupmedia.com is generated at build time by an Astro endpoint. The endpoint declares its full route surface in getStaticPaths and serves a PNG body from a GET handler — Astro’s static-file-endpoint pattern for non-.astro outputs.
// src/pages/og/[...slug].png.ts
export async function getStaticPaths() {
const manifest = await getOgManifest();
return manifest.map((entry) => ({
params: { slug: entry.slug },
props: { entry },
}));
}
export const GET: APIRoute = async ({ props }) => {
const node = renderTemplateFor(props.entry);
const png = await renderOg(node);
return new Response(png, {
headers: { "Content-Type": "image/png", "Cache-Control": "public, max-age=31536000, immutable" },
});
};
getOgManifest() enumerates everything that needs a card — the homepage, two archive pages, every blog post, every case study, in both EN and ES. The endpoint hands the matched entry to one of four templates and returns the PNG.
The four templates: default (archives + fallback), article (blog posts), case-study (portfolio entries), and home (the cinematic homepage card). All live as .tsx files in src/lib/og/templates/, each exporting a function that returns a JSX-shaped node tree. Satori does the rendering — there’s no React runtime involved.
The whole system is ~600 lines of TypeScript. It generates dozens of cards on every build, and the only piece of infra it needs is
resvg-js.
Satori in one paragraph
Satori takes a JSX-shaped node tree (no React, just the shape) plus a list of font buffers and returns an SVG. Vercel describes it as the engine behind their @vercel/og library. Shu Ding, the author, frames Satori as “the first CSS-to-SVG render engine that does not rely on a browser environment”. You feed that SVG to @resvg/resvg-js and out the other end comes a PNG.
Vercel published concrete numbers comparing the result against headless Chrome: P99 cold-start time-to-first-byte dropped from 4.96s to 0.99s, and the deploy bundle from ~50MB (Chromium + Puppeteer) to ~500KB — roughly 5× faster and 100× lighter.

// src/lib/og/render.ts
import satori from "satori";
import { Resvg } from "@resvg/resvg-js";
export async function renderOg(node: ReactNode): Promise<Uint8Array> {
const fonts = await ogFontConfig();
const svg = await satori(node, { width: 1200, height: 630, fonts });
return new Resvg(svg, { fitTo: { mode: "width", value: 1200 } }).render().asPng();
}
What Satori actually parses (a correction we owe ourselves)
The first version of this post claimed Satori “passes our SVG mascots through correctly, gradients and CSS-class fills and all.” That’s wrong, and the README is explicit about it: Satori does not support <style> tags or external resources via <link> or <script>. It also restricts you to a flexbox subset of CSS — the same subset implemented by Yoga — and accepts only TTF, OTF, and WOFF font files (no WOFF2). Variable-font support isn’t mentioned in the README at all; treat it as unsupported until proven otherwise.
So how do our mood cats render? They’re embedded as <img src="data:image/svg+xml;base64,..."> data URLs. When Satori encounters an <img>, it inlines the source as an <image> element in the output SVG; the SVG’s own <defs><style> block survives that round-trip and is handled by resvg when it rasterizes the composite. Credit for our CSS-class fills working belongs to resvg-js, not Satori.
Mood cats, hashed by slug
Brand has fourteen mood-based mascot SVGs sitting in public/assets/mascot/. Names like happy, loving, intrigued, super-big-surprise. We wanted every blog post and case study to get its own mood — but the same mood every time, so social shares are stable.
Solution: deterministic hash from slug to mood, on a curated subset.
// src/lib/og/assets.ts
const MOODS = [
"big-surprise", "happy", "intrigued", "loving",
"super-big-surprise", "surprise", "Suspicious",
] as const;
function hashSlug(slug: string): number {
let h = 5381;
for (let i = 0; i < slug.length; i++) h = ((h << 5) + h) ^ slug.charCodeAt(i);
return h >>> 0;
}
export function pickMoodForSlug(slug: string) {
const names = [...loadMoodSvgs().keys()];
const name = names[hashSlug(slug) % names.length];
return { name, dataUrl: moods.get(name)! };
}
The curated set is half the available moods — we dropped bored, caught, hypnotized, and a few more because they read as ambiguous or negative in a card thumbnail. “Suspicious” stayed because it doubles as “intensely focused.”
The hash function is djb2, originally posted by Dan Bernstein on comp.lang.c in 1991. The chosen seed (5381) and multiplier (33) were picked because they produced “fewer collisions and better avalanching” than alternatives — the magic of 33 has never been adequately explained. Good enough for slug-to-index.
CTAs that rotate without being random
Each card has a pill button at the foot — “Read more →”, “The whole story →”, “Ver el armado →”. We didn’t want them static (boring) and we didn’t want them truly random (changes per refresh, breaks share previews). Same trick as the moods: hash the slug, mod the pool length.
const CTAS = ["Read more", "Read on", "Keep reading", /* ...20 total */] as const;
export function pickCtaForSlug(slug: string, locale: "en" | "es" = "en"): string {
const pool = locale === "es" ? CTAS_ES : CTAS;
const idx = ((hashSlug(slug) ^ 0xa5a5a5a5) >>> 0) % pool.length;
return pool[idx];
}
The XOR with 0xa5a5a5a5 rotates the hash so the CTA pick is decorrelated from the mood pick. Two posts with the same mood still get different CTAs.
There are four pools in total: blog EN, blog ES, case-study EN, case-study ES. Each is 20 entries, hand-curated. Blog pools lean reading-focused (“Take a bite”, “Keep reading”). Case-study pools lean project-focused (“See the build”, “Mira cómo lo cocinamos”). The locale and template together pick which pool.
The bug we caused
When we first shipped the CTA picker, ~30% of cards rendered with no CTA pill. Empty navy box, no text. The function looked right.
The fix took ten minutes of console.log to find:

JavaScript’s bitwise operators are normatively specified to convert their operands to 32-bit signed integers. MDN’s “Fixed-width number conversion” reference puts it plainly: “Bitwise operators… convert the operands to 32-bit integers… using two’s complement encoding”. The MDN page for the bitwise XOR operator restates: “For numbers, the operator returns a 32-bit integer”. The clearest demonstration is the bitwise NOT operator: ~5 === -6. The underlying algorithm is in ECMA-262 §6.1.6.1.16 (NumberBitwiseOp), which calls §7.1.6 (ToInt32) — both produce signed results.
Because 0xa5a5a5a5 has its high bit set, the XOR result for any slug whose hash hits the wrong bit pattern is negative. CTAS[-19] is undefined. Then pickCtaForSlug returns undefined and the template’s {cta && (...)} guard silently skips the pill.
The fix coerces the XOR result back to unsigned before the modulo, using JavaScript’s only unsigned bitwise operator — the >>> 0 “zero-fill right shift by zero” idiom. Cards started rendering CTAs again.
The lesson, once again, is that in JavaScript,
^is a signed operation and%doesn’t care that the result went negative. Anything you hash into an array index, pipe through>>> 0first.
A note on the PRNG choice elsewhere in the system: we use Mulberry32 for any non-hash random work. The author’s gist says it passes gjrand’s “13 statistical tests” on 4GB of output — solid enough for cosmetic randomness. The same gist also notes that it isn’t equidistributed and misses around a third of uint32 values, which is fine for our use case (picking from small pools) but would be the wrong choice for cryptographic or simulation work.
The homepage card
Three of the templates share a layout language — saffron mascot panel, navy title, hairline footer. The fourth, home, is its own thing: deep purple radial gradient, kinetic typography in the style of the live hero (“SMALL / BRANDS / BIG APPETITES.”), and a saffron disc housing the brand mark. We made it the one card that doesn’t fit a template grid.
For the Spanish variant, we sourced the copy from i18n/es.json’s hero strings directly — IDEAS / FRESCAS / UN GRAN APETITO. Same shape, locale-correct content.
A small wrinkle on this card: two-word lines (e.g. “BIG APPETITES.”) overflow the title column at 92px in Spanish because Spanish characters run wider. We added one heuristic — any line containing a space drops to 72px:
const size = line.includes(" ") ? 72 : 92;
No font measurement, just a guess that happens to be right for the homepage copy in both locales. We’ll tighten the heuristic when a third language joins.
Production deployment considerations
We bake every card at build time, so the runtime cost of the system is zero. That’s a deliberate choice given what’s downstream. Cloudflare Pages and Workers have hard limits we wanted to stay clear of: 128 MB of memory per isolate, 10ms of CPU on the Free plan (up to 5 minutes on Paid), and a 10MB compressed Worker bundle on Paid. The @resvg/resvg-js WASM binary is sizable, and rendering at request time would burn a meaningful slice of that CPU budget per card.
There’s a separate package, workers-og, that exists specifically because the way @vercel/og bundles WASM doesn’t load cleanly under Cloudflare Workers; its README notes the issue directly. We avoid that whole problem by pre-rendering — but if we ever needed runtime cards (UTM-tagged share links, A/B variants), workers-og is where we’d start.
What we’d do differently
A few things we already want to revisit:
- Variable fonts. We ship JetBrains Mono and Titillium Web as fixed-weight TTFs because Satori’s variable-font support isn’t documented. The full Urbanist family lives in
public/fonts/and we’d love to wire it up — needs more testing in Satori. - A web-worker render path. Right now every OG card lives in
getStaticPaths, baked at build time. If we ever need on-demand cards, we’d move the renderer into a Cloudflare Worker viaworkers-og. - A self-test fixture. We hand-rendered every template once after each change. A snapshot test that diffs the output PNGs against committed fixtures would have caught the CTA bug in seconds.
The whole stack
For anyone wanting to clone the pattern, it’s three packages:
satori— JSX → SVG@resvg/resvg-js— SVG → PNG- Whatever framework you’re already on for the route layer (we use Astro)
Plus the brand assets: one mascot per mood, the brand fonts as raw TTF buffers, and a curated palette. The total system size — code + fonts + SVGs — is under 400KB.
The factory’s been running for two weeks. We’ve changed templates four times, swapped a CTA pool once, and shipped one bug fix. The cards still look like a brand, not a default.
— The Geek Cat

