The OG card generator that couldn't run where I built it to run
I had three hand-committed PNGs standing in for this site’s social cards: one default terminal-window card, one Deep Cut Atlas card, and nothing at all for the blog. No template survived anywhere in the repo, just exported images from whatever design tool I’d used the day I made them. Any time I wanted to tweak the copy or ship a new flagship app, I’d be back in that tool, re-exporting by hand.
The fix was supposed to be simple: build a shared card-generation package once, point every site at it, done. It mostly was. The part that wasn’t taught me something about where Astro’s own build actually runs code.
Building the generator
The shared piece, @vdaluz/astro-og-cards, wraps three libraries: satori turns an HTML/CSS string into an SVG, satori-html adapts a plain HTML string into the object shape Satori wants, and something rasterizes that SVG into a PNG. I reached for @resvg/resvg-js first, since it’s the usual pairing.
It crashed. Not a normal exception, an uncatchable Rust panic, the moment the source markup had a box-shadow or text-shadow in it. My default card design has both, so this wasn’t an edge case, it was every real card I wanted to generate. I bisected it down to Satori’s feDropShadow/feGaussianBlur filter output specifically. Swapped resvg-js for sharp, same input, worked immediately, glow and all.
Two more gotchas showed up before I trusted the pipeline: macOS’s system monospace font is a variable font, and Satori’s bundled font parser can’t read those, so I had to bundle a static weight instead. And reading that bundled font from a sibling file at runtime, using any path computed relative to the module’s own location, broke the moment a real consumer’s bundler (Vite) relocated the compiled module into a different chunk file. The fix there was to just embed the font as base64 directly in the JS, so there’s no file path to get wrong.
The card that needed a face
Deep Cut Atlas’s card isn’t a simple text card, it’s got a gold vinyl-record globe illustration on it. I found the source SVG for that illustration already sitting in the repo as the app’s icon, which meant I didn’t have to redraw anything, just rasterize it once at build time and drop it in as an image. The recreation came out close enough to the original that a side-by-side comparison was really the only way to sign off on it, eyeballing a description wouldn’t have told me anything useful.
The blog posts got a similar treatment: a title-bearing card by default, and when a post has a hero image, that photo becomes the card’s background with a gradient overlay so the title stays readable. Zero of my current posts actually have a hero image set yet, since that field’s mostly used on the sister blog. I built the photo variant anyway. The tooling here isn’t scoped to what one site happens to be using today.
Where it actually broke
Wiring all three cards into the site, I built dedicated Astro endpoints. src/pages/og/blog/[...slug].png.ts, prerender = true, call the generator, return the PNG. This is the standard Astro pattern for build-time image generation and it’s exactly what I’d used to validate the shared package itself.
astro build failed:
Error: No such module "dist/server/.prerender/chunks/sharp".
Astro’s Cloudflare adapter doesn’t prerender routes in plain Node. It runs them inside a Miniflare-simulated Workers sandbox, to match what production actually does. That’s the right call for accuracy, but it means no native addon can run there, full stop. sharp is a compiled binary. No compatibility flag fixes that, because the problem isn’t a missing JS shim, it’s that a V8 isolate can’t execute native code at all.
My earlier validation hadn’t caught this because I’d tested the package against a plain output: 'static' scratch app, which prerenders in ordinary Node. The gap was invisible until I ran the actual build on the actual site.
The fix was to stop trying to generate images from inside Astro’s build at all. I moved the generation into a standalone script that runs before the build, writes PNGs straight into public/, and wired it as npm’s prebuild hook so it fires automatically. The site’s pages already just reference /og/blog/<slug>.png as a normal static path, so nothing about the URL wiring had to change, only how the file got there.
That script hit its own version of the same class of problem: Node refuses to strip TypeScript types for anything living inside node_modules, which blocked importing the shared package (shipped as raw, uncompiled TypeScript) from a plain script. Running the script through tsx instead of plain node sidestepped it, since tsx uses esbuild for the transform and doesn’t carry that restriction.
What’s different now
Three hand-committed PNGs are gone. In their place: three template functions, one generation script, and a build step that regenerates every card automatically, including one per blog post in both languages. The Deep Cut Atlas card’s gold tagline now pulls straight from the site’s own translation strings instead of whatever text I’d typed into a design tool months ago, so it can’t drift out of sync with the actual page copy again.
The part I’d flag for anyone building something similar: verify the mechanism against the real target, not a structurally simpler stand-in. A static-output scratch app was a fine way to prove the card templates rendered correctly, and it did. It could never have told me that the actual deployment target runs prerendered routes somewhere a native addon can’t follow.
Related reading
Proving a fallback actually falls back
A test that passes whether or not your override option works is not a test, it's a coin flip that happens to land the same way every time.
Clearing a backlog: five small fixes in one release
A dead-decoration a11y fix, a schema field that validated but never rendered, and a formatter run that silently flipped quote style across four files.
The escaping bug that only shows up with a second query param
A rewrite function that worked in every test and every real URL it had ever seen, and would still have broken the moment someone added a second query param.