Use Cases for HTML to Image
HTML-to-image conversion is needed across a wide range of backend tasks:
- OG image generation — dynamic social preview images from HTML templates
- Email thumbnails — rendered previews of HTML email layouts
- Certificate & badge generation — diploma PDFs, achievement images
- Social card exports — "Share your stats" type images
- Invoice/report previews — thumbnail images of generated documents
- Chart exports — rendering Chart.js or D3 charts to PNG for email reports
The approach you choose depends on your infrastructure constraints and rendering fidelity requirements.
Option 1: node-html-to-image
The most popular npm package for this use case. Wraps Puppeteer with a clean API for converting HTML strings or Handlebars templates to images.
npm install node-html-to-image
const nodeHtmlToImage = require('node-html-to-image');
// Basic HTML string to PNG
await nodeHtmlToImage({
output: './output.png',
html: \`
Hello, World!
Generated with node-html-to-image
\`
});
Handlebars Templates
One of node-html-to-image's best features is native Handlebars support for dynamic content:
const nodeHtmlToImage = require('node-html-to-image');
// Generate multiple images from a template
await nodeHtmlToImage({
output: './og-images/', // or use buffer: true for in-memory
html: \`
{{title}}
\`,
content: [
{ title: 'Getting Started with TypeScript', author: 'Alex', date: 'Apr 4, 2026' },
{ title: 'React 19 Deep Dive', author: 'Maria', date: 'Apr 3, 2026' },
{ title: 'Node.js Performance Tips', author: 'Sam', date: 'Apr 2, 2026' },
]
});
// Generates og-images/0.png, og-images/1.png, og-images/2.png
Buffer output (no disk write)
// Get Buffer directly — useful for API responses
const image = await nodeHtmlToImage({
html: 'Hello',
type: 'jpeg',
quality: 90,
});
// image is a Buffer — send directly in HTTP response
res.set('Content-Type', 'image/jpeg');
res.send(image);
node-html-to-image wraps Puppeteer, so it ships the full 280MB Chromium binary. It launches a new browser per call unless you manage the lifecycle yourself. Under any real load this will cause memory issues. It's great for low-volume batch jobs — not for on-demand per-request rendering.
Option 2: Puppeteer Directly
More control than node-html-to-image — you manage the browser lifecycle explicitly, enabling browser reuse:
const puppeteer = require('puppeteer');
class HtmlToImageService {
constructor() {
this.browser = null;
}
async init() {
this.browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage']
});
}
async render(html, { width = 1200, height = 630, type = 'png' } = {}) {
const page = await this.browser.newPage();
try {
await page.setViewport({ width, height, deviceScaleFactor: 2 });
await page.setContent(html, { waitUntil: 'networkidle0' });
const buffer = await page.screenshot({ type, fullPage: false });
return buffer;
} finally {
await page.close(); // important: close page, not browser
}
}
async close() {
if (this.browser) await this.browser.close();
}
}
// Usage
const renderer = new HtmlToImageService();
await renderer.init();
const buf = await renderer.render(\`
Dynamic OG Image
\`);
// Express endpoint
app.get('/og/:title', async (req, res) => {
const html = buildTemplate(req.params.title);
const buf = await renderer.render(html);
res.set({ 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' });
res.send(buf);
});
Option 3: Playwright
Similar to Puppeteer but with better TypeScript support and more flexible waiting strategies:
const { chromium } = require('playwright');
async function htmlToImage(html, { width = 1200, height = 630 } = {}) {
const browser = await chromium.launch();
const page = await browser.newPage();
try {
await page.setViewportSize({ width, height });
await page.setContent(html, { waitUntil: 'networkidle' });
// Optional: wait for specific font/image to load
await page.evaluate(() => document.fonts.ready);
const buffer = await page.screenshot({
type: 'png',
clip: { x: 0, y: 0, width, height }
});
return buffer;
} finally {
await browser.close();
}
}
// With local fonts
async function htmlToImageWithFonts(html) {
const browser = await chromium.launch();
const page = await browser.newPage();
// Inject base64-encoded font to avoid CDN dependency
await page.addStyleTag({
content: \`
@font-face {
font-family: 'Inter';
src: url('data:font/woff2;base64,AAAA...') format('woff2');
}
\`
});
await page.setContent(html);
await page.evaluate(() => document.fonts.ready);
const buf = await page.screenshot();
await browser.close();
return buf;
}
External Google Fonts requests often fail or time out in headless environments due to network restrictions. Embed fonts as base64 data URIs, or use system fonts like -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif for reliable rendering without network deps.
Option 4: SnapAPI (No Browser Required)
For HTML-to-image at scale, or in environments where you can't run a browser (serverless, edge), SnapAPI's screenshot endpoint accepts raw HTML directly via the html parameter instead of a URL:
async function htmlToImage(html, options = {}) {
const response = await fetch('https://api.snapapi.pics/v1/screenshot', {
method: 'POST',
headers: {
'X-Api-Key': process.env.SNAPAPI_KEY,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html, // raw HTML string
viewport_width: options.width || 1200,
viewport_height: options.height || 630,
format: options.format || 'png',
full_page: false,
wait_until: 'networkidle',
}),
});
if (!response.ok) throw new Error(\`SnapAPI error: \${response.status}\`);
return Buffer.from(await response.arrayBuffer());
}
// Generate OG image from template
const template = (title, author) => \`
myblog.com
\${title}
\`;
// Express endpoint — zero browser infra
app.get('/og-image', async (req, res) => {
const { title = 'My Post', author = 'Anonymous' } = req.query;
const buf = await htmlToImage(template(title, author));
res.set({
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=604800', // 1 week
});
res.send(buf);
});
SnapAPI's /v1/screenshot accepts either a url (screenshot a live page) or an html string (render your template). For OG image generation and certificate rendering, the html mode is ideal — no hosting required for the template.
Performance Comparison
p50 latency, 1200×630 OG image template, external fonts from CDN. Measured April 2026.
Which Approach to Use?
| Approach | Best for | Avoid when |
|---|---|---|
| node-html-to-image | One-off batch jobs, simple CLI scripts | Per-request production API, serverless |
| Puppeteer (managed) | High-control server environment, custom browser flags | Lambda/serverless, tight memory budgets |
| Playwright | Multi-browser support, modern TypeScript codebase | Lambda/serverless, tight memory budgets |
| SnapAPI | Serverless, edge, any scale without browser management | Air-gapped environments, sub-100ms SLAs |
OG Image Generation at Scale
For SaaS products generating OG images for every user/post/product, per-request rendering adds up fast. Here's a caching pattern that works with any backend:
const crypto = require('crypto');
const { Redis } = require('ioredis');
const redis = new Redis(process.env.REDIS_URL);
const CACHE_TTL = 60 * 60 * 24 * 7; // 7 days
async function getOgImage(params) {
// Deterministic cache key from template params
const cacheKey = 'og:' + crypto
.createHash('sha256')
.update(JSON.stringify(params))
.digest('hex')
.slice(0, 16);
// Check cache first
const cached = await redis.getBuffer(cacheKey);
if (cached) return cached;
// Generate with SnapAPI
const buf = await htmlToImage(buildTemplate(params));
// Cache for 7 days
await redis.setex(cacheKey, CACHE_TTL, buf);
return buf;
}
// Express endpoint with cache
app.get('/api/og', async (req, res) => {
try {
const buf = await getOgImage({
title: req.query.title,
author: req.query.author,
category: req.query.category,
});
res.set({
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=604800',
'ETag': crypto.createHash('md5').update(buf).digest('hex'),
});
res.send(buf);
} catch (err) {
res.status(500).json({ error: err.message });
}
});
Summary
For small-batch or development use, node-html-to-image is the fastest path to a working solution. For production at any meaningful scale, either manage your browser lifecycle explicitly with Puppeteer/Playwright and a pool, or offload rendering to a service like SnapAPI to eliminate the operational burden entirely.
The html parameter in SnapAPI's screenshot endpoint makes it a drop-in replacement for node-html-to-image with better performance and zero infrastructure overhead. Get 200 free calls to test it in your pipeline.