Three ways to turn an HTML string into a PNG from Node.js, all run on the same 600×315 social card. Tested with node-html-to-image 6.2, Puppeteer 25 and Node 22.
| Option | Pick it when |
|---|---|
| node-html-to-image A Puppeteer wrapper | You want Handlebars templates and batches in a few lines, and don't mind Chromium in your node_modules. |
| Puppeteer Chromium, full control | You need element crops, waits for fonts or charts, or one browser reused for thousands of images. |
| An API Chromium on someone else's server | Vercel, Lambda or a slim Docker image where a 150 MB browser doesn't fit. |
Satori (what @vercel/og uses) is a different approach: it turns JSX into SVG without a browser, so it's very fast on the edge. The catch is that it supports a subset of CSS (flexbox, no grid), and you load font files yourself, so it can't render arbitrary HTML or a live page. Use it if you write the card as JSX from scratch. html2canvas runs in the visitor's browser, not in Node; see the html2canvas alternative guide.
Save this as card.html: a fixed-size box with a gradient and flexbox layout, like most generated images.
<!doctype html>
<html><head><meta charset="utf-8"><style>
body { margin: 0; font-family: sans-serif; }
.card { width: 600px; height: 315px; box-sizing: border-box; padding: 40px;
display: flex; flex-direction: column; justify-content: space-between;
background: linear-gradient(135deg, #4f46e5, #0ea5e9); color: #fff; }
h1 { margin: 0; font-size: 40px; }
</style></head><body>
<div class="card"><h1>Release 2.4 is out</h1><p>Faster builds, smaller bundles.</p></div>
</body></html>
The snippets below use top-level await, so save them as .mjs files (or set "type": "module" in package.json).
npm install node-html-to-image
It installs Puppeteer (and its Chromium) as a dependency and screenshots the page for you.
import nodeHtmlToImage from "node-html-to-image";
import { readFile } from "node:fs/promises";
const html = await readFile("card.html", "utf8");
await nodeHtmlToImage({
output: "./card.png",
html,
selector: ".card",
puppeteerArgs: { defaultViewport: { width: 1200, height: 800, deviceScaleFactor: 2 } },
});
Result: a 1200×630 PNG (600×315 at 2×). Watch the defaults: without selector it captures body in an 800px-wide window, so our 600px card came out as an 800×315 image with a white strip on the right. Set selector to your card's element and it crops exactly. Other things to know:
html is a Handlebars template, and content fills it. Pass an array to render a batch in one browser: content: [{ name: "Ada", output: "./ada.png" }, { name: "Linus", output: "./linus.png" }] with {{name}} in the HTML.output and it returns a Buffer instead, which is handy in an HTTP handler.type: "jpeg" with quality and transparent: true for a PNG without a background (remove the CSS background too). It waits for the load event only, so for charts drawn later use Puppeteer directly.args: ["--no-sandbox"] to puppeteerArgs, ideally only for HTML you trust.npm install puppeteerimport puppeteer from "puppeteer";
import { readFile } from "node:fs/promises";
const html = await readFile("card.html", "utf8");
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 800, deviceScaleFactor: 2 });
await page.setContent(html, { waitUntil: "networkidle0" });
await page.evaluate(() => document.fonts.ready);
const card = await page.$(".card");
await card.screenshot({ path: "card.png" });
await browser.close();
Result: the same 1200×630 PNG, cropped to the card. This is what node-html-to-image does underneath, so you get the same pixels with full control. Variations:
await page.screenshot({ path: "out.png", fullPage: true }).path; in Puppeteer 22+ it returns a Uint8Array, so wrap it with Buffer.from() before res.send().{ type: "jpeg", quality: 85 }. Transparent PNG: { omitBackground: true }.setContent calls.puppeteer.launch({ args: ["--no-sandbox"] }).fonts-noto-core fonts-noto-color-emoji), or text shows as boxes. Our Puppeteer screenshot guide covers "Could not find Chrome" and the sandbox error.Shotmint renders in the same Chromium, but your code only makes an HTTP request, so it works on Vercel or Lambda as is. selector crops to an element like card.screenshot():
import { readFile, writeFile } from "node:fs/promises";
const html = await readFile("card.html", "utf8");
const res = await fetch("https://shotmint.creasework.com/v1/screenshot", {
method: "POST",
headers: { "X-API-Key": process.env.SHOTMINT_KEY, "Content-Type": "application/json" },
body: JSON.stringify({ html, selector: ".card", device_scale_factor: 2 }),
});
if (!res.ok) throw new Error(await res.text());
await writeFile("card.png", Buffer.from(await res.arrayBuffer()));
Result: the same 1200×630 PNG. Other options: format (png, jpeg, webp), quality, width/height for the viewport, full_page, omit_background, delay in milliseconds to wait for scripts, and "url": "https://..." to capture a live page instead of HTML. Images and fonts must be reachable from the internet or inlined as data URIs. See the Node.js API guide for retries and error handling.
| Feature | Support |
|---|---|
| Crop to an element | All three (selector, element.screenshot())node-html-to-image defaults to body at 800px wide |
| Templates and batches | node-html-to-image: built in (Handlebars) Puppeteer and API: loop and fill the HTML yourself |
| Retina (2×) | All three (deviceScaleFactor, device_scale_factor) |
| Install size | API: none (built-in fetch)node-html-to-image and Puppeteer: plus a Chromium download |
For batches of templated images on a server you control, node-html-to-image is the least code; just set selector. If you need waits, element crops or browser reuse, use Puppeteer directly. On serverless or in a slim container, the API skips the browser entirely. No code at all? Paste your HTML into the free HTML to image converter. Social cards from a template: dynamic og:images. Python instead: HTML to image in Python. PDFs: HTML to PDF in Node.js.
No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire).
Get a free API key Full API reference