DEV Community

pickuma
pickuma

Posted on • Originally published at pickuma.com

How Our OG Image Generation Pipeline Works (Satori, resvg, and a Cloudflare Worker)

Every article on this site ships with its own Open Graph image — the card that shows up when a link gets pasted into Bluesky, Slack, iMessage, or a dev.to feed. We don't draw those by hand. A pipeline renders one per post from the article's own frontmatter, and we want to walk through exactly how it works, because the parts are simpler than most "dynamic OG image" tutorials make them look.

The short version: we take JSX, turn it into SVG with Satori, turn that SVG into a PNG with resvg, and serve the result from a Cloudflare Worker route. Three moving pieces. The rest of this is the detail that decides whether the output looks deliberate or looks like a default.

What an OG image actually has to do

The job is narrower than it sounds. An OG image is 1200×630 pixels, it has to be a PNG or JPEG (SVG is not honored by most scrapers), and it has to be reachable at a stable URL referenced in a <meta property="og:image"> tag. That's the whole contract. Everything past that is design.

What makes it annoying is that the image has to be derived from content you already have — the title, category, and read time live in MDX frontmatter — without a human opening Figma for each of several hundred posts. So the real requirement is: given a row of structured data, produce a deterministic, legible 1200×630 PNG, and do it fast enough that a social scraper hitting the URL cold doesn't time out.

The two constraints that bite are font rendering and text length. Titles on this site run from 30 to 120 characters. A layout that looks balanced at 40 characters overflows its container at 110. So the template isn't a fixed design — it's a layout that has to reflow, which is the single reason we use Satori instead of compositing a pre-made background with text on top.

The pipeline, stage by stage

Here's the path a single image takes, from frontmatter to PNG.

1. Read the post. An Astro endpoint at /og/[slug].png.ts resolves the matching content entry and pulls title, category, and readTimeMinutes out of its frontmatter. No database call — the content collection is already in memory at build time.

2. Build the layout as JSX. We describe the card as a flexbox tree: a dark panel, a category eyebrow, the title in a large weight, and a footer with the read time and the pickuma wordmark. This is plain JSX, but it is never sent to a browser. It exists only to be measured.

3. Satori measures and emits SVG. Satori is Vercel's library that takes a subset of JSX plus CSS flexbox and produces an SVG with every glyph positioned absolutely. It does the line-wrapping and box layout that a browser would normally do, but headless. This is the stage that handles the 40-vs-110-character problem: give it flex and word-wrap, and it computes the wrap points itself.

4. resvg rasterizes to PNG. Satori's SVG still references fonts and vector paths. resvg (we use the WASM build) flattens it into a real 1200×630 PNG buffer. That buffer is what we return.

5. The Worker serves and caches it. The route sets a long Cache-Control and an ETag. The first scraper to request a given slug pays the render cost once; Cloudflare's edge cache serves every request after that. Since the inputs are deterministic, the cache key is just the slug.

Fonts are the part that will waste your afternoon. Satori does not read system fonts and it does not download anything. You have to hand it the raw font bytes for every weight and every character set you reference. We ship two weights of one typeface as .ttf files loaded into the Worker. The first time a title contained a character outside that font's range, Satori didn't error — it silently dropped the glyph, and the PNG shipped with a missing letter. If you build this, test a title with an em dash, a curly quote, and an accented name before you trust it.

The reason we render at build time for known posts, rather than purely on demand, is latency honesty. A cold render — Satori plus resvg-WASM — takes a few hundred milliseconds in a Worker. That's fine for a scraper, but doing it on the first real reader's request adds nothing for them, so we pre-warm the cache for every published slug during the deploy and let on-demand rendering exist only as the fallback for anything not yet warmed.

What broke, and what we'd tell you to skip

Three things cost us real time, and one thing we built turned out to be unnecessary.

The missing-glyph bug above was the worst, because it failed quietly. The second was emoji: emoji are color glyphs, Satori needs an explicit emoji resolver to fetch SVG versions of them, and without one they render as empty boxes. We removed emoji from the OG template entirely rather than carry that dependency — the card looks cleaner without them anyway.

The third was caching during development. Because the route is so aggressively cached at the edge, a template change wouldn't show up until we busted the cache or changed the slug. We added a ?v= cache-buster for local testing and a deploy step that purges the OG namespace, which removed the "why isn't my change showing" confusion.

The thing we'd skip: a configurable template system. We started building a way to define multiple card layouts per category and pick between them. After two weeks of one layout, nobody wanted a second one, and the abstraction was pure overhead. One good template that reflows beats four rigid ones. If you're building this for your own site, write the single layout you actually want and stop there.

The payoff is mundane and worth it: every link we publish arrives in a feed with a legible, on-brand card generated from data we already had, with zero per-post design work. The pipeline is roughly 150 lines. Most of the value is in the four files of font loading and the discipline to keep the template to one.


Originally published at pickuma.com. Subscribe to the RSS or follow @pickuma.bsky.social for new reviews.

Top comments (0)