Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

For an existing HTML/CSS template, the most reliable Node.js workflow is to render your data into HTML, load that HTML in Puppeteer, wait until fonts and other required assets are ready, and call page.pdf(). Chromium performs the same layout work as a browser, so your CSS, web fonts, tables and responsive rules can be reused instead of rebuilt with PDF drawing commands.

The recommended workflow

  1. Compile the template with escaped data (Handlebars, EJS, Nunjucks or your own renderer).
  2. Launch a pinned Puppeteer/Chromium version.
  3. Open the rendered HTML with page.setContent() or navigate to a controlled URL.
  4. Wait for network resources, fonts and application-specific content.
  5. Choose print or screen media deliberately, then call page.pdf().
  6. Close the page and reuse the browser process for the next job.

Puppeteer’s PDF API uses print CSS media by default. That is normally correct for invoices and reports, but templates designed only for screens may need page.emulateMediaType('screen'). Add -webkit-print-color-adjust: exact in print CSS when background colors must be preserved.

A complete Node.js example

Install and prepare a template

This example uses an HTML template function to keep the setup independent of a particular templating package. In production, replace the function with Handlebars, EJS or another engine that escapes user-controlled values.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
npm install puppeteer

Create generate-pdf.mjs:

import puppeteer from 'puppeteer';
import { writeFile } from 'node:fs/promises';

function escapeHtml(value) {
  return String(value)
    .replaceAll('&', '&')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;')
    .replaceAll('"', '&quot;')
    .replaceAll("'", '&#39;');
}

function renderInvoice(data) {
  const rows = data.items.map(item => `
    <tr>
      <td>${escapeHtml(item.description)}</td>
      <td class="number">${item.quantity}</td>
      <td class="number">${item.price.toFixed(2)}</td>
      <td class="number">${(item.quantity * item.price).toFixed(2)}</td>
    </tr>`).join('');

  return `<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>
    @page { size: A4; margin: 20mm 15mm; }
    * { box-sizing: border-box; }
    body { font: 12pt Arial, sans-serif; color: #222; margin: 0; }
    h1 { color: #123b66; margin: 0 0 8px; }
    .meta { color: #555; margin-bottom: 24px; }
    table { width: 100%; border-collapse: collapse; }
    th, td { border-bottom: 1px solid #ddd; padding: 8px 4px; text-align: left; }
    th { background: #edf3f8; }
    .number { text-align: right; }
    .total { margin-top: 18px; text-align: right; font-size: 14pt; font-weight: bold; }
    tr { break-inside: avoid; }
    @media print { body { -webkit-print-color-adjust: exact; print-color-adjust: exact; } }
  </style>
</head>
<body>
  <h1>Invoice ${escapeHtml(data.number)}</h1>
  <div class="meta">${escapeHtml(data.customer)} · ${escapeHtml(data.date)}</div>
  <table>
    <thead><tr><th>Description</th><th class="number">Qty</th><th class="number">Price</th><th class="number">Amount</th></tr></thead>
    <tbody>${rows}</tbody>
  </table>
  <div class="total">Total: ${data.total.toFixed(2)}</div>
</body>
</html>`;
}

const data = {
  number: 'INV-1007',
  customer: 'Acme Ltd',
  date: '2026-09-29',
  items: [
    { description: 'Design work', quantity: 2, price: 450 },
    { description: 'Hosting', quantity: 1, price: 80 }
  ],
  total: 980
};

const html = renderInvoice(data);
const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: 'networkidle0' });
  await page.evaluate(async () => {
    if (document.fonts?.ready) await document.fonts.ready;
    await Promise.all([...document.images].map(img => img.complete
      ? Promise.resolve()
      : new Promise(resolve => { img.addEventListener('load', resolve); img.addEventListener('error', resolve); })));
  });
  await page.pdf({
    path: 'invoice.pdf',
    format: 'A4',
    printBackground: true,
    preferCSSPageSize: true,
    margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' }
  });
} finally {
  await browser.close();
}

Run it with node generate-pdf.mjs. The resulting invoice.pdf uses the template’s page rules, includes background colors, and waits for fonts and images before printing.

Template engines and safe data

Handlebars or EJS

Compile the template before giving it to Puppeteer. Configure the engine’s normal escaping syntax for names, addresses and notes. Only allow an explicit, sanitized HTML field to bypass escaping. Never concatenate untrusted text into a <script>, style block or event-handler attribute.

Images, fonts and charts

Base64 or local assets avoid an extra network dependency. For remote assets, ensure the Chromium process can reach the host and wait for them explicitly. Client-rendered charts need an application readiness signal—for example, set window.pdfReady = true after the chart finishes, then wait with page.waitForFunction(() => window.pdfReady === true). Puppeteer waits for fonts during PDF generation, but images, charts and other asynchronous work still require your readiness strategy.

Print CSS that survives real data

  • Define @page size and margins, or pass equivalent PDF options.
  • Use break-inside: avoid for invoice rows, cards and signatures that must stay together.
  • Add explicit headers and footers when page numbers or legal text are required; test them with long documents.
  • Use fixed units such as millimetres for page geometry and test long names, large tables and empty fields.
  • Keep print-only rules in @media print. Use screen media only when the design intentionally depends on screen styles.

PDF options such as landscape, displayHeaderFooter, headerTemplate, footerTemplate and pageRanges let the application control output without changing the source template. Keep page-break decisions in CSS where possible so browser previews and PDFs agree.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When to choose another library

Approach Best fit Trade-off
Puppeteer plus HTML/CSS Invoices, reports, certificates and branded layouts already expressed as web templates Requires Chromium and browser-process operations
PDFKit Code-defined drawings, text and streams without browser layout You must implement layout, wrapping and pagination with PDF primitives
Handlebars wrapper such as pdf-creator-node Less integration code around HTML templates Retains Chromium startup and deployment cost; its documentation lists Node.js 18 or newer

Use PDFKit when the source is not HTML and exact control over drawing commands or streams matters. A wrapper can be convenient for a small team, but it does not remove the browser requirement.

Performance, reliability and deployment

Reuse the browser

Launching Chromium for every document adds avoidable startup work. Keep one browser process per worker, create a fresh page for each job, and always close the page in a finally block. Recycle the browser periodically if your workload exposes memory growth.

Pin and cache dependencies

Pin the Puppeteer version and its compatible Chromium revision in deployment. Cache the browser binary in CI rather than downloading it during every build. Run a smoke test that generates a PDF after deployment.

Isolate untrusted templates

HTML can execute JavaScript and request network resources. For user-supplied templates, use a separate worker or container, restrict outbound access, set job timeouts, cap document size, and avoid exposing secrets in environment variables reachable by page scripts. Do not allow arbitrary navigation when a job only needs local HTML.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Validate output

Test representative data: one-line and multi-line names, empty sections, maximum table lengths, unusual Unicode, missing images and slow fonts. Compare page count, file size and key text in automated checks, then visually inspect page breaks for every template revision.

Troubleshooting

The PDF is blank or missing late content

Cause: the page was printed before client rendering completed. Fix: use waitUntil: 'networkidle0', wait for a known selector or readiness flag, and await chart/image completion.

Background colors disappear

Cause: print backgrounds are disabled or the stylesheet lets the browser adjust colors. Fix: pass printBackground: true and add -webkit-print-color-adjust: exact to print CSS.

The layout differs from the browser preview

Cause: PDF output uses print media by default. Fix: inspect print styles, or call page.emulateMediaType('screen') when the template is intentionally screen-designed. Also confirm the same viewport and fonts are used.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Fonts or images are missing

Cause: inaccessible URLs, blocked requests, CORS or a race condition. Fix: use reachable HTTPS or embedded assets, log failed requests, wait for document.fonts.ready and image completion, and verify the deployed Chromium network policy.

Chromium fails to launch in a container

Cause: missing shared libraries, sandbox restrictions or an incompatible binary. Fix: use a base image supported by your pinned Puppeteer release, install required system dependencies, and follow your platform’s documented sandbox configuration rather than disabling security blindly.

Large documents time out

Cause: oversized HTML, slow external assets or expensive client-side scripts. Fix: embed or cache stable assets, remove unnecessary JavaScript, set an explicit job timeout, split very large reports, and reuse the browser process.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

If your source is already a publicly reachable page, ScreenshotNeo can render that URL to a PDF through one request. It is not a replacement for compiling a private template inside your Node process, but it avoids packaging and operating Chromium for URL-based captures.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

For PDF output, use the PDF option documented in the ScreenshotNeo API documentation and set the target URL to your deployed, authenticated-free template page. The service also supports full-page capture, custom CSS and JavaScript, waiting for selectors or network idle, viewport and device settings, PDF paper size, margins, orientation and page ranges.

Its cleanup step accepts cookie or consent banners and removes more than 60 known consent platforms, newsletter popups and chat widgets before capture; each step can be disabled. Bot checks or CAPTCHAs, blank pages, timeouts, failed loads and cache hits are not billed, and response headers identify the page verdict and billing status. An MCP server provides take_screenshot, get_page_info and capture_pdf tools for Claude, Cursor and other MCP clients. The Free plan includes 1,000 shots per month without a card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

cURL, Python and Node.js request examples

The same endpoint is useful when another service owns template rendering. Replace the example URL with your deployed page and add the documented PDF parameters.

import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
r.raise_for_status()
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`ScreenshotNeo returned ${res.status}`);

Frequently Asked Questions

Can Puppeteer generate a PDF without writing an HTML file first?

Yes. Render the template to a string and pass it directly to page.setContent(); a temporary file is unnecessary.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Should I use print or screen media for an invoice?

Use print media unless the template was deliberately designed around screen-only rules. Select screen media only after checking its page breaks and colors in a PDF.

Is PDFKit compatible with an existing CSS template?

Not directly. PDFKit draws text and shapes through JavaScript, so an HTML/CSS template normally fits Puppeteer better.

How do I generate only selected pages?

Pass a page range such as pageRanges: '1-3' to page.pdf(), after testing the document’s pagination.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.