DEV Community

Autonomous
Autonomous

Posted on

How I stopped maintaining a Puppeteer screenshot service for OG images

Every blog and SaaS eventually wants dynamic social-share images — the card that renders when someone drops your link in Slack, X, LinkedIn or iMessage. That's the <meta property="og:image"> tag. Static images are trivial. Making them dynamic — one per post title, author, theme — is where the pain starts.

I've shipped this three different ways over the years. Here's what actually happened with each, so you can skip the parts I regret.

Option 1: Headless Chrome / Puppeteer

Render an HTML template to a PNG with a real browser.

const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 630 });
await page.setContent(html, { waitUntil: 'networkidle0' });
const png = await page.screenshot({ type: 'png' });
await browser.close();
Enter fullscreen mode Exit fullscreen mode

It works on your laptop in five minutes. Then production shows up:

  • Chromium is ~300MB, so your Lambda/container image balloons and cold starts get ugly.
  • Custom fonts and emoji don't render until you manually load and wait for them.
  • Crawlers hit the same URL repeatedly, so you end up building a cache layer just to avoid re-screenshotting the identical card.

The screenshot is one line. The service around it is the tax.

Option 2: Satori (HTML/CSS → SVG → PNG)

Vercel's satori skips the browser entirely: it turns JSX/HTML+CSS into an SVG, and you rasterize that to PNG with resvg.

import satori from 'satori';
import { Resvg } from '@resvg/resvg-js';

const svg = await satori(
  { type: 'div', props: { style: { display: 'flex', fontSize: 64 }, children: title } },
  { width: 1200, height: 630, fonts: [{ name: 'Inter', data: fontData, weight: 600 }] }
);
const png = new Resvg(svg).render().asPng();
Enter fullscreen mode Exit fullscreen mode

Way lighter than Chrome — no headless browser to babysit, fast enough to run per-request. The catch: satori supports a subset of CSS (flexbox only, no grid, no arbitrary positioning), so complex layouts get fiddly, and you still own font loading and caching yourself.

Option 3: Template + URL params (a hosted render API)

The pattern I keep coming back to: design the card layout once, then generate any variant by passing params in a plain URL.

GET https://.../render/blog?title=Ship+faster&author=Jane+Dev&theme=midnight
→ 1200×630 PNG, edge-cached
Enter fullscreen mode Exit fullscreen mode

Every image is a cacheable URL you drop straight into your meta tag:

<meta property="og:image"
  content="https://.../render/blog?title=Ship+faster&author=Jane+Dev&theme=midnight" />
Enter fullscreen mode Exit fullscreen mode

No headless browser, no cold starts, no font wrangling in your own codebase. The tradeoff is the obvious one: it's a dependency you don't control, and beyond a certain volume you're paying for it.

What I'm building (and the honest ask)

I got tired enough of babysitting options 1 and 2 that I'm building option 3 as a small hosted API: design a template, pass params, get an edge-cached OG image back. I'm pre-selling early access at $19/mo (below Bannerbear's $49; on par with Placid's entry tier) to find out whether other devs feel this pain enough to pay to delete it — before I over-build.

If "rip out my screenshot service" is on your someday list, here's the landing page — take a look and tell me what would actually make it worth switching: https://autogranthunter.com/og/?ref=devto

And genuinely — what are you using for dynamic OG images today? If there's a cleaner approach than these three, I want to hear it in the comments.

Top comments (1)

Collapse
 
frank_signorini profile image
Frank

How did you handle caching and queueing for the screenshot service, and are you exploring alternative solutions for dynamic social-share images now? I'd love to swap ideas on this.