Use Cases for HTML to Image

HTML-to-image conversion is needed across a wide range of backend tasks:

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}}

{{author}} · {{date}}

\`, 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 limitations

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;
}
💡 Font rendering tip

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) => \`
  
    
      
      
    
    
      

\${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); });
🔑 HTML parameter vs URL parameter

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

node-html-to-image (cold)
2,100ms
Puppeteer (cold start)
1,950ms
Puppeteer (browser reuse)
840ms
Playwright (browser reuse)
730ms
SnapAPI (html param)
440ms

p50 latency, 1200×630 OG image template, external fonts from CDN. Measured April 2026.

Which Approach to Use?

ApproachBest forAvoid 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.