Generating Open Graph Images at Build Time
How this blog generates its own social sharing images using satori and resvg-wasm, no external services required.
Every time you share a link on Twitter, LinkedIn, or Slack, the platform looks for an Open Graph image. If you don't have one, you get a boring blank card. If you do, your link actually looks like something worth clicking.
I didn't want to design these by hand for every post. So this blog generates them automatically at build time.
Here's how it works.
The tools
Two libraries do the heavy lifting:
- satori takes a JSX-like object tree and turns it into an SVG. Think of it as a tiny layout engine that understands flexbox.
- @resvg/resvg-wasm converts that SVG into a PNG. It runs as WebAssembly, so there's no native dependency to install.
Both run in Node.js. No headless browser, no Puppeteer, no Chrome.
The setup
Install both:
npm install satori @resvg/resvg-wasmYou'll also need a font file. I'm using Source Serif 4 in regular and bold weights, stored in a fonts/ directory.
The code
The image generation lives right inside the build script. Here's the core of it:
import satori from "satori";
import { Resvg, initWasm } from "@resvg/resvg-wasm";
import { readFile } from "fs/promises";
import { readFileSync, writeFileSync } from "fs";
// Initialize resvg's WASM module once
await initWasm(
await readFile("node_modules/@resvg/resvg-wasm/index_bg.wasm")
);
// Load fonts
const fonts = [
{
name: "Source Serif",
data: readFileSync("fonts/SourceSerif4-Regular.ttf"),
weight: 400,
style: "normal",
},
{
name: "Source Serif",
data: readFileSync("fonts/SourceSerif4-Bold.ttf"),
weight: 700,
style: "normal",
},
];Then for each post, I build a layout and render it:
const markup = {
type: "div",
props: {
style: {
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: "72px 80px",
backgroundColor: "#fbf9f7",
fontFamily: "Source Serif",
},
children: [
{
type: "div",
props: {
style: {
fontSize: "64px",
fontWeight: 700,
color: "#2c2825",
},
children: title,
},
},
{
type: "div",
props: {
style: {
fontSize: "24px",
color: "#77716e",
borderTop: "2px solid #e6e2de",
paddingTop: "32px",
},
children: "The Portrait of a Geek",
},
},
],
},
};
const svg = await satori(markup, {
width: 1200,
height: 630,
fonts,
});
const resvg = new Resvg(svg, {
fitTo: { mode: "width", value: 1200 },
});
const png = resvg.render().asPng();
writeFileSync(`output/og/${slug}.png`, png);That's the whole thing. No templates, no HTML, no canvas. Just an object tree that describes a layout.
How satori works
Satori's markup looks like JSX but it's plain objects. Each node has a type (like "div" or "img"), props with a style object, and children. It supports a subset of CSS flexbox, which is more than enough for a card layout.
A few things to know:
- It only supports flexbox. No grid, no floats, no position absolute.
- Every text node needs an explicit font. It won't fall back to system fonts.
- Images work as data URIs, including SVGs. I embed my logo this way.
- The
fontSizeadapts based on title length. Longer titles get a smaller size so they don't overflow.
Hooking it into the build
During the build, each post and page pushes its title and slug onto a queue. After all the HTML is generated, the script initializes the WASM module once and loops through the queue:
await initOg();
for (const { title, slug } of ogQueue) {
await generateOgImage(title, slug);
}The images land in output/og/ and the HTML meta tags point to them:
<meta property="og:image" content="https://theportraitofageek.com/og/building-widgetizer.png?v=a1b2c3d4">I also append a small version hash to the image URL. That matters because social networks are aggressive about caching OG images. If I change the card design later, the query string changes too, which nudges them to fetch the new image instead of holding on to an old one forever.
Since everything is static, the images deploy alongside the HTML. No runtime generation, no caching layer, no external service.
Why not a service?
Services like Vercel's OG or Cloudflare Workers can generate these on the fly. I actually built a Worker version first. It worked fine, but then I thought about it:
- The images only change when I change the design or publish a new post.
- I was exposing an endpoint that anyone could hit with arbitrary text.
- It was an extra thing to deploy and monitor.
Generating at build time is simpler. One process, one output directory, nothing to abuse. And if I want to tweak the design, I change the code, rebuild, and every image updates. The versioned og:image URL makes sure those updates are actually visible when the links get shared again.
The result
Each build generates a PNG for every post, every page, and the homepage. The whole thing takes under five seconds.
If you want to see the output, share this post on Twitter or LinkedIn. That card you see? It was generated by the code above.