In JavaScript there are two different jobs called "HTML to image". On a server (Node.js), use Puppeteer: it opens real Chrome, page.setContent(html) loads your markup and element.screenshot() returns a PNG. In the browser, libraries such as html-to-image and html2canvas turn a DOM node into a canvas or data URL without a server — but they redraw the page themselves, so cross-origin images, some CSS and iframes go missing. If you need Chrome's rendering without running Chrome, one fetch to an HTML to image API does it.
Every snippet below ran on Puppeteer 25.7, html-to-image 1.11.13, html2canvas 1.4.1 and Node 22.
In Node: Puppeteer and real Chrome
npm i puppeteer # also downloads a matching Chrome for Testing
Puppeteer's install downloads Chrome for Testing (about 282 MB on Linux) plus chrome-headless-shell into ~/.cache/puppeteer. Use puppeteer-core instead when you bring your own browser.
// card.mjs
import puppeteer from 'puppeteer';
const html = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<style>
body { margin: 0; }
.card {
width: 1200px; height: 630px; box-sizing: border-box;
display: grid; align-content: center; gap: 16px; padding: 80px;
background: linear-gradient(135deg, #0f172a, #1e3a8a);
color: #f8fafc; font: 700 64px/1.15 system-ui, sans-serif;
}
.card small { font-size: 28px; font-weight: 400; color: #93c5fd; }
</style>
</head>
<body>
<div class="card">
<small>Release notes · v2.4</small>
HTML to image in Node, rendered by Chrome
</div>
</body>
</html>`;
const browser = await puppeteer.launch();
try {
const page = await browser.newPage();
await page.setViewport({ width: 1200, height: 630, deviceScaleFactor: 2 });
await page.setContent(html, { waitUntil: 'load', timeout: 15_000 });
await page.evaluate(() => document.fonts.ready);
const card = await page.$('.card');
await card.screenshot({ path: 'card.png' }); // 2400×1260, the element only
await page.screenshot({ path: 'card.webp', type: 'webp', quality: 85 });
await page.screenshot({ path: 'full.png', fullPage: true });
const bytes = await page.screenshot({ clip: { x: 0, y: 0, width: 600, height: 315 } }); // a Buffer
} finally {
await browser.close();
}
Details that trip people up:
waitUntil. ForsetContent, Puppeteer acceptsload,domcontentloadedornetworkidle; thenetworkidle0/networkidle2values are excluded there (SetContentWaitForOptions).loadwaits for images and stylesheets;document.fonts.readywaits for web fonts.- Relative URLs do not resolve.
setContentputs your HTML onabout:blank— we checked:document.baseURIisabout:blank, so<img src="/logo.png">loads nothing. Use absolutehttps://URLs ordata:URIs. - Fonts. Chrome uses what is installed or loaded by
@font-face. A slim Docker image without emoji or CJK fonts draws empty boxes for those characters. - Sharpness.
deviceScaleFactor: 2renders at double density; the file is twice the viewport in each dimension.
In production, keep one browser open and open a page per render (launching costs most of a second), cap concurrency, time out every render and restart Chrome periodically. If users can send HTML, treat it as hostile: it can request internal addresses from your network unless you block requests with page.setRequestInterception(true) or isolate the browser. The Puppeteer screenshot guide covers capturing URLs.
In the browser: html-to-image and html2canvas
These run in the user's tab and never touch a server. They do not take a screenshot — the browser has no API for that — they rebuild one:
- html2canvas walks the DOM and draws each element onto a canvas with its own JavaScript renderer. Its documentation says it "gathers information on all the elements there, which it then uses to build a representation of the page", and its feature list marks properties such as
filter,mix-blend-mode,object-fit,border-imageandwriting-modeas not supported. - html-to-image clones the node, inlines computed styles, fonts and images as data URLs, wraps the clone in an SVG
<foreignObject>and lets the browser paint that SVG onto a canvas (how it works). The browser does the painting, so more CSS survives.
import { toPng } from 'html-to-image';
const dataUrl = await toPng(document.getElementById('card'), { pixelRatio: 2 });
const link = Object.assign(document.createElement('a'), { download: 'card.png', href: dataUrl });
link.click();
import html2canvas from 'html2canvas';
const canvas = await html2canvas(document.getElementById('card'), { scale: 2, useCORS: true });
canvas.toBlob((blob) => upload(blob), 'image/png');
We tested both in Chromium against the failure cases people report:
| Case | html2canvas 1.4.1 | html-to-image 1.11.13 |
|---|---|---|
<img> from another origin without CORS headers |
Left out of the image, no exception | The whole toPng call rejects |
Same, with allowTaint: true |
toDataURL throws "Tainted canvases may not be exported" |
— |
filter: invert(1) |
Ignored | Rendered |
<iframe> content (same origin) |
Not drawn | Not drawn |
So: serve images from your own origin or with Access-Control-Allow-Origin, keep the captured node free of iframes, and check effects you rely on. Fonts have the same rule — html-to-image fetches the font files to embed them, which needs CORS for fonts on a CDN. And the output depends on the visitor's browser and installed fonts, so two users can get two different images. That is fine for "download this chart"; it is a problem for social cards that must look identical everywhere.
The same image from an API with fetch
An HTML to image API gives you Chrome's rendering from any runtime with fetch (Node 18+, Deno, Bun, Workers). With Shareglint you save the HTML and CSS once as a template with fields, then send only the values:
const API = 'https://shareglint.com/v1';
const headers = {
Authorization: `Bearer ${process.env.IMAGE_API_KEY}`,
'Content-Type': 'application/json',
};
async function post(path, body) {
const res = await fetch(`${API}${path}`, { method: 'POST', headers, body: JSON.stringify(body) });
if (!res.ok) throw new Error(`${res.status} ${await res.text()}`);
return res.json();
}
// 1. Save the template once (a deploy script, not every request).
await post('/templates', {
name: 'Article card',
slug: 'article-card',
width: 1200,
height: 630,
fields: ['kicker', 'title', 'author'],
html: '<div class="card"><p class="kicker">{{ kicker }}</p>'
+ '<div class="fitbox"><h1 class="fit" data-autofit>{{ title }}</h1></div>'
+ '<p class="by">{{ author }}</p></div>',
css: '.card{height:100%;padding:72px;display:grid;grid-template-rows:auto 1fr auto;'
+ 'gap:24px;background:#0f172a;color:#f8fafc;font-family:Roboto,sans-serif}'
+ '.kicker{color:#fbbf24;font-size:26px;text-transform:uppercase}'
+ 'h1{font-size:76px;line-height:1.08}.by{font-size:28px;color:#94a3b8}',
});
// 2. One request per image.
const image = await post('/images?wait=true', {
template: 'article-card',
format: 'png',
modifications: {
kicker: 'Engineering',
title: 'How we cut our build time in half',
author: 'Ada Lovelace',
},
});
console.log(image.url);
Run step 1 once: a second run answers 422 because the slug is taken (update with PATCH /v1/templates/{id}). Built-in templates like blog-og skip step 1. The same fields again return the existing URL and use no quota; data-autofit shrinks a long title to fit. Never call this from browser code — the key would be public. Use a server route, or a signed /i/ URL that needs no key (docs).
The trade-off is control: scripts are removed when a template is saved, external URLs in the HTML and CSS are dropped (images arrive through image fields), and fonts come from the installed list — you cannot upload your own. Puppeteer has none of those limits. Free accounts get 100 images a month with a watermark; Starter is $9 for 2,000 (pricing).
Which one to use
- Download-this-element in a web app (charts, receipts a user saves): html-to-image or html2canvas, after testing the CSS your element uses.
- Server-side images where you run Chrome anyway: Puppeteer (or Playwright).
- OG images in Next.js with flexbox layouts:
@vercel/og— and the guide to its limits for when it is not enough. - Server-side images without operating Chrome: an API.
More: which npm package renders what, and the same task in Python, PHP and C#.