How Our OG Image Generation Pipeline Works (Satori, resvg, and a Cloudflare Worker)
A look inside the pipeline that renders a unique social preview image for every article on pickuma.com — the components, the failure modes, and what we'd skip.
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.
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.
Cursor
We built and iterated on this pipeline inside Cursor — the layout JSX, the Satori config, and the Worker route. Tight feedback loops matter when you're eyeballing pixel output against a 1200×630 frame.
Free tier; Pro from $20/mo
Affiliate link · We earn a commission at no cost to you.
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.
FAQ
Why not just use a screenshot service or a static fallback image?+
Do you render at build time or on demand?+
What's the hardest part to get right?+
Related reading
2026-06-08
What Our llms.txt Is, and Why We Publish It
Pickuma ships two machine-readable files for AI crawlers: an index and a full corpus. Here's what's in them, how they're generated, and why a review site publishes its own content to language models.
2026-05-28
AI-Assisted Writing Disclosure: Where We Draw the Line
Most 'AI-assisted' badges are vague. Here's the binary threshold we use for flagging articles, why FTC and E-E-A-T guidance pushed us there, and the edge cases that still leak.
2026-05-26
AI Agent Pipelines for Developer Productivity: What Actually Saves Hours
We tested a four-stage AI agent pipeline for code review, test generation, and deployment over two weeks. Here's where the gains are real and where the failure modes hide.
2026-05-26
NVIDIA CUTLASS Review: CUDA Templates for GEMM Kernels Behind Modern LLMs
NVIDIA CUTLASS provides CUDA C++ templates and Python DSLs for building custom GEMM kernels. We examine where it fits versus cuBLAS, what the abstraction costs you, and when to reach for it.
2026-05-26
GPT-5.5 Instant vs GPT-5.3 Instant: Testing OpenAI's Three Claims
OpenAI silently swapped ChatGPT's default from GPT-5.3 Instant to GPT-5.5 Instant. We break down which of the three official claims — speed, reasoning, accuracy — hold up in independent testing, and what to do if you ship on the API.
Get the best tools, weekly
One email every Friday. No spam, unsubscribe anytime.