# Convert HTML to image in JavaScript: Node, browser or API

> HTML to PNG in JavaScript: Puppeteer in Node (real Chrome), html-to-image and html2canvas in the browser (and where they break), or one fetch to an API.

Published 2026-11-04 · https://shareglint.com/en/guides/html-to-image-javascript

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

```bash
npm i puppeteer   # also downloads a matching Chrome for Testing
```

[Puppeteer's install](https://pptr.dev/guides/installation) 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.

```js
// 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`.** For `setContent`, Puppeteer accepts `load`, `domcontentloaded` or `networkidle`; the `networkidle0`/`networkidle2` values are excluded there ([`SetContentWaitForOptions`](https://pptr.dev/api/puppeteer.setcontentwaitforoptions)). `load` waits for images and stylesheets; `document.fonts.ready` waits for web fonts.
- **Relative URLs do not resolve.** `setContent` puts your HTML on `about:blank` — we checked: `document.baseURI` is `about:blank`, so `<img src="/logo.png">` loads nothing. Use absolute `https://` URLs or `data:` 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: 2` renders 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](https://shareglint.com/en/guides/puppeteer-screenshot) 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](https://html2canvas.hertzen.com/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](https://html2canvas.hertzen.com/features) marks properties such as `filter`, `mix-blend-mode`, `object-fit`, `border-image` and `writing-mode` as 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](https://github.com/bubkoo/html-to-image#how-it-works)). The browser does the painting, so more CSS survives.

```js
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();
```

```js
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](https://shareglint.com/en/html-to-image) 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:

```js
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](https://shareglint.com/en/docs#signed)).

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](https://shareglint.com/en/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](https://shareglint.com/en/guides/nextjs-og-images-without-vercel-og-limits) for when it is not enough.
- **Server-side images without operating Chrome**: an API.

More: [which npm package renders what](https://shareglint.com/en/guides/html-to-image-npm), and the same task in [Python](https://shareglint.com/en/guides/html-to-image-python), [PHP](https://shareglint.com/en/guides/html-to-image-php) and [C#](https://shareglint.com/en/guides/html-to-image-csharp).

## FAQ

### How do I convert HTML to an image in Node.js?

Use Puppeteer: launch Chrome, call page.setContent(html), set the viewport with deviceScaleFactor for sharpness, then elementHandle.screenshot() or page.screenshot() to get a PNG, JPEG or WebP. It is real Chrome, so all CSS renders as in the browser.

### Why is my html2canvas image missing pictures or effects?

html2canvas does not take a screenshot; it rebuilds the page on a canvas from computed styles. Images from other origins without CORS headers are left out (or taint the canvas so it cannot be exported), and some CSS such as filter is not supported. Its own feature page lists the unsupported properties.

### What is the difference between html2canvas and html-to-image?

html2canvas re-implements rendering in JavaScript and draws onto a canvas. html-to-image clones the DOM into an SVG foreignObject and lets the browser paint it, so it supports more CSS, but it still has to inline every image and font, which fails for cross-origin files without CORS.

### Can I generate an image from HTML on the server without Chrome?

Yes, with limits: Satori (used by @vercel/og) lays out JSX/HTML with its own engine and outputs SVG, which you then rasterise. It is not a browser. For full CSS without running Chrome, send the HTML to an HTML to image API.
