Generating Open Graph images with Puppeteer means rendering an HTML card at 1200×630 in headless Chrome and saving a screenshot of it. The core is ten lines; the work is around them: keeping Chrome alive, waiting for fonts, escaping titles, caching, and serving the file fast to crawlers. Below is a complete version, with numbers we measured on 2026-11-06 using Puppeteer 25.7.0 in the official Docker image (with an emoji font added) on 2 CPU cores, and an honest note on when an API is less work.
The card
The template is plain HTML and CSS at the exact size. Flexbox, gradients, web fonts and emoji work as in any page — that is the main advantage over SVG-based generators:
const escape = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const card = ({ title, site }) => `<!doctype html><html><head><style>
html, body { margin: 0; width: 1200px; height: 630px; }
body { display: flex; flex-direction: column; justify-content: space-between;
padding: 72px; box-sizing: border-box; color: #fff;
background: linear-gradient(135deg, #0f172a, #1e3a8a); font-family: "DejaVu Sans", sans-serif; }
h1 { font-size: 64px; line-height: 1.1; margin: 0; }
footer { font-size: 28px; opacity: .8; }
</style></head><body><h1>${escape(title)}</h1><footer>${escape(site)}</footer></body></html>`;
Escape every value. Titles come from your CMS and users. Without escaping, we put <img src=x onerror="…"> into the title and the script in it ran inside the renderer. In a browser that can fetch URLs, that is a server-side request forgery waiting to happen; at the very least it breaks the card.
The renderer
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch(); // once per process
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 630, deviceScaleFactor: 1 });
let queue = Promise.resolve(); // one render at a time on this page
export function renderOgImage(data) {
const job = queue.then(async () => {
await page.setContent(card(data), { waitUntil: 'load' });
await page.evaluate(() => document.fonts.ready);
return page.screenshot({ type: 'jpeg', quality: 85 });
});
queue = job.catch(() => {});
return job;
}
Measured with a warm browser, twenty different titles:
| Median per image | File size | |
|---|---|---|
| JPEG, quality 85 | 39 ms | 39 KB |
| PNG | 163 ms | 322 KB |
| Launch, render and close Chrome for each image | about 740 ms | — |
JPEG was four times faster than PNG for this gradient card and eight times smaller. Use PNG for flat colours with sharp text, where it is often smaller than JPEG, and keep deviceScaleFactor: 1 — link previews show the image well below 1200 px wide.
Cache it, or it renders on every crawl
Crawlers fetch og:image again and again — every share on every platform. Render once per distinct card:
import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
export async function ogImageFile(data) {
const key = createHash('sha256').update(JSON.stringify(data)).digest('hex').slice(0, 16);
const file = `/var/cache/og/${key}.jpg`;
if (!existsSync(file)) await writeFile(file, await renderOgImage(data));
return file;
}
Serve the file with a long Cache-Control (the URL changes when the data changes), or upload it to object storage and put that URL into og:image. Rendering at build time for a static site is even simpler: loop over your posts and write the files next to the HTML.
Production details
- Memory. Chrome with one page used about 470 MB summed over its processes in our container — an upper bound, because shared memory is counted in each process, but plan for at least 1 GB on the machine. A 512 MB serverless function is tight for Chrome.
- Fonts and emoji. Containers usually lack emoji and some scripts; the official Puppeteer image had no emoji font. Install the font packages, or embed your fonts as data URLs. Details in emoji and fonts in Puppeteer.
- Images in the card. Remote images (author photos, covers) are fetched by Chrome during
setContent(Puppeteer docs); a slow image host slows every render. Download and inline them, or set a short timeout. - Concurrency. One page renders one card at a time; for more throughput open a small pool of pages — size it by measuring on your machine — not a browser per request.
- Crashes. Listen for
browser.on('disconnected')and relaunch; a renderer that silently stops leaves every new share without an image. - Security. Turn off JavaScript for the card if you do not need it (
page.setJavaScriptEnabled(false)) and block network requests you do not expect with request interception.
When not to do it yourself
Your own Puppeteer is a good choice when you already run a Node server with memory to spare, the cards are few, and you are happy to own Chrome updates and fonts. It is the wrong one when:
- Your host is serverless or small (Vercel, Netlify, Cloudflare, a 512 MB container): Chrome's size and memory are the problem, not the code.
- The site is static or a no-code builder with no server to run Chrome on.
- You need many sizes and formats — the link preview, a square post, a story — from the same design.
Shareglint is that renderer as a service: HTML templates (153 built in, or your own HTML and CSS), 6 size presets from one design, JPEG/PNG/WebP/PDF, caching of identical requests, and signed URLs you can put straight into og:image from a static site, with no API call at render time (signed image URLs). The Free plan has 100 renders a month with a watermark; Starter is $9 a month for 2,000 (pricing). For framework-specific setups see the OG image generator pages; for Next.js without @vercel/og limits, this guide.