No OG-Image Service — Making Share Cards With Headless Chrome
You need a 1200×630 social share image. No design tool, no third-party OG service, no dynamic generation — write an HTML file with the site's own CSS variables and screenshot it with headless Chrome. And why a static default first, deferring per-article generation until it's actually needed.
While adding Open Graph to the site, I got stuck on one image: og:image needs a 1200×630 share card — the thing that shows up when a link is shared to a social platform. The question wasn’t “can I make one,” it was “which way do I make it that doesn’t saddle me with a long-term burden.”
Four options, three of them too heavy
- Draw it by hand in a design tool: pull one up in Figma, export. Works, but it lives outside the code — change a word or a color and you reopen the tool and redo it by hand, out of sync with the site’s design tokens, drifting eventually.
- A third-party OG service (the various
og-image.vercel.app-style ones): easy, but it introduces an external dependency. The last thing a personal site wants is “one more thing that could go down, change its pricing, or get blocked.” - Dynamic per-request generation: run satori / resvg in a Worker, rendering the title into an image live. This is a legitimate approach many large sites use — but it’s a runtime to deploy and maintain, and standing it up for what is currently a single shared default image is using a sledgehammer on a nail.
- Render HTML and screenshot it: write an HTML file, lay it out with the site’s own styles, have headless Chrome take a PNG.
I picked the fourth.
Write one HTML with the site’s design tokens
The site is terminal-styled: dark #0a0d11, mint #3ee6a8, JetBrains Mono, a fine grid background, a bit of glow at the top, and a ~/mahui.me wordmark with a block cursor. All of those are existing CSS variables. An OG image is just those same tokens laid out on a 1200×630 canvas:
<style>
html, body { width: 1200px; height: 630px; }
body {
background: #0a0d11;
font-family: 'JetBrains Mono', monospace;
display: flex; align-items: center; justify-content: center;
}
/* fine grid */
body::before {
background-image:
linear-gradient(rgba(62,230,168,.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(62,230,168,.05) 1px, transparent 1px);
background-size: 48px 48px;
}
.wordmark { font-size: 84px; font-weight: 700; color: #e8eef2; }
.wordmark .prompt { color: #3ee6a8; }
.cursor { /* mint block, mimicking a terminal cursor */ }
</style>
The key property is right here: this image uses the same design tokens as the site, so it can never drift visually from it. It’s not “redraw something that looks like the site,” it’s “lay out a canvas with the site’s actual colors and fonts” — same #3ee6a8, same mono font, same ~/ prefix.
One command to screenshot
Chrome ships headless screenshotting; no Puppeteer, no library to install:
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \
--headless --disable-gpu \
--screenshot="og-default.png" \
--window-size=1200,630 \
--hide-scrollbars \
"file://$PWD/og.html"
--window-size=1200,630 matches the OG spec dimensions exactly, --hide-scrollbars stops a scrollbar from eating a few edge pixels. Out comes a 1200 x 630, 8-bit RGB PNG; drop it in public/images/og-default.png and reference it from the head. The whole process is zero-dependency, zero-runtime, and the artifact is one static file.
Why a static default first
Someone will ask: why not go straight to per-article dynamic generation, baking each post’s title into its image? Then every shared article gets a bespoke card — which does look better.
The answer is YAGNI. The actual need right now is “every page has a share image that isn’t embarrassing,” and one shared brand default satisfies that. Per-article generation solves “each one different,” and that value — an article title on the share card — doesn’t clear the priority bar yet: the site’s share volume isn’t at the point where “a bespoke image per post” produces perceptible gain. Taking on a rendering runtime for a need that hasn’t arrived is textbook premature optimization.
But this approach leaves a smooth path forward: if per-article generation ever is worth it, the HTML template is already there. Add a title placeholder to that HTML, loop over every post at build time and screenshot each, and it grows from “one default image” into “one per post” — same design tokens, same screenshot command, just run N times instead of once. Static-first isn’t giving up on dynamic; it’s deferring dynamic until the day it’s worth it, without closing off that path.
Takeaways
- If a build-time static artifact solves it, don’t stand up a runtime. A currently-shared single image isn’t worth the deployment and upkeep of a satori rendering service;
- Let derived assets reuse the source design tokens. Render the OG image with the site’s own CSS variables and it can never drift from the site’s look — “draw it to match” drifts, “lay it out with the same variables” doesn’t;
- Headless Chrome is an underrated build tool. Screenshots, PDF generation, rendering anything “expressible in HTML but needing to become an image” — one command, zero dependencies, no need to reach for Puppeteer;
- Static-first needs a clean upgrade path. Do the default image first, but write it as “a template that can take a title placeholder,” and dynamic generation stays a single
forloop away rather than a rewrite.
Comments