Menu
Sign up

Generate OG images with Puppeteer yourself — and when not to

Build your own OG image endpoint with Puppeteer: template, setContent, caching, escaping. Measured: 39 ms per warm JPEG, 0.7 s cold, ~470 MB RAM.

Published November 6, 2026 · 4 min read

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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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.

FAQ

How do I generate an OG image with Puppeteer?

Render an HTML card at 1200×630 with page.setContent, wait for fonts, and call page.screenshot. Keep one browser running, escape every value you put into the HTML, and cache the result by its inputs so each card renders once.

How fast is it?

In our test with a warm browser, a 1200×630 card took a median 39 ms as JPEG and 163 ms as PNG, including setContent. Launching Chrome for each image added about 0.7 seconds.

How much memory does it need?

Chrome with one page used about 470 MB summed over its processes in our container (an upper bound, as shared memory is counted more than once). Plan for at least 1 GB on the machine that renders.

When is an API better than my own Puppeteer?

When you would rather not run Chrome in production — on serverless hosts, small servers or static sites — or when you need many sizes, signed URLs for og:image and caching without building them. For a site that already runs a Node server with memory to spare, your own Puppeteer is fine.