Generating dynamic Open Graph images on Cloudflare Workers
All articles Every page and article on this site has its own Open Graph image, generated on demand at the edge. No design tool, no pre-baked PNGs checked into the repo. A single Worker route renders them with Satori and @cf-wasm/og.<br>The theme fits the brand: a starfield, a faint graticule grid, and a teal accent, so a shared link looks like a small piece of a map rather than a generic card.<br>The route<br>The endpoint is a catch-all under /og. It takes a title and description from the query string, builds a Satori element tree, and returns a PNG:<br>// server/routes/og/[...path].png.ts<br>export default defineEventHandler(async (event) => {<br>const { title, description } = getQuery(event);<br>// build a Satori element tree, render to PNG, return it<br>});<br>Pages reference their own card through useSeoMeta, and articles build the URL from the post's title and description:<br>const ogImageUrl = computed(() => {<br>const title = encodeURIComponent(article.value?.title ?? 'Articles');<br>const description = encodeURIComponent(article.value?.description ?? '');<br>return `https://vinayakkulkarni.dev/og/articles/${slug}.png?title=${title}&description=${description}`;<br>});<br>What Satori refuses to do<br>Satori is not a browser. It renders a strict subset of layout, and it is unforgiving about it. Two rules cost me the most time:<br>Every div with two or more children must declare display: flex. Satori throws otherwise. This means you cannot lay out a card the way you would in normal HTML; you are writing flexbox explicitly at every level.<br>Satori also chokes on an empty children: []. A helper that constructs elements needs to omit the children key entirely when there are none, rather than passing an empty array. I ended up with a small el(type, style, ...children) helper that flattens and filters its children, sets children to a single string when there is exactly one text child, and leaves the key off when there are none.<br>The upside of Satori being this strict is that once a card renders, it renders identically every time. There is no browser variance to chase.<br>The numbers<br>Cold renders land around 0.5 to 1.1 seconds. Warm renders, served from cache, are roughly 50 milliseconds. Because the cards are deterministic (same query string in, same PNG out) they cache cleanly with immutable cache headers, so the cold cost is paid once per unique card and never again.<br>The whole thing runs on workerd, the same runtime the site itself runs on, with WebAssembly enabled for the font and rendering work. No separate image service, no build step that pre-generates hundreds of PNGs, and no stale cards when a title changes. Edit the title, the next request renders a fresh card.<br>Why bother<br>Social cards are the first impression of a link before anyone clicks it. Hand-designing one per page does not scale, and pre-baked defaults make every share look the same. Generating them at the edge means every page and every article gets a card that reflects its actual content, for the cost of one small Worker route and a render that caches after the first hit.
cloudflareworkersopen-graphsatorinuxt