Skip to content

How to convert HTML to PDF in Node.js

Two approaches that work in production, with the code for each: run headless Chrome yourself with Puppeteer, or send the HTML to an API and get the PDF back.

By Saurav N, senior software engineer · Updated

· 6 min read

Option 1: Puppeteer and headless Chrome

Puppeteer drives a real Chrome, so the PDF looks like the page does in the browser: modern CSS, web fonts and @page rules all work. page.pdf() prints the page the way Chrome’s print dialog would.

Set printBackground: true, or background colours and table stripes disappear. waitUntil: networkidle0 waits for images and fonts before printing.

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

const html = '<h1>Invoice INV-2026-0142</h1><p>Total ₹49,402.50</p>';

const browser = await puppeteer.launch();
try {
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: 'networkidle0' });
  const pdf = await page.pdf({ format: 'A4', printBackground: true, margin: { top: '16mm', bottom: '16mm' } });
  await writeFile('invoice.pdf', pdf);
} finally {
  await browser.close();
}

What running Chrome yourself costs

It works well, and it is the right call for many teams. The work is around it:

  • Each Chrome process takes a few hundred megabytes of memory; under load you pool browsers or pages and restart the ones that leak.
  • Server images need Chrome and its system libraries, plus the fonts your documents use (including Indic scripts, if you print them).
  • Rendering on the request path makes slow PDFs slow responses; most teams move it to a queue.

Option 2: one call to an HTML to PDF API

An API does the Chrome part for you. With Templater the HTML lives in a template your team edits in the browser, and your code sends only the data. ?wait=20 holds the request until the PDF is ready, and returnAs: "binary" returns the file itself.

render-api.mjs
import { writeFile } from 'node:fs/promises';

const res = await fetch('https://templater.nestutils.com/v1/pdfs/render?wait=20', {
  method: 'POST',
  headers: { Authorization: `Bearer ${process.env.TEMPLATER_KEY}`, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    template: 'gst-invoice',
    returnAs: 'binary',
    data: { invoice: { number: 'INV-2026-0142', total: 49402.5 }, customer: { name: 'Priya Menon' } },
  }),
});

if (res.status === 202) {
  // Still rendering after 20 s: poll GET /v1/pdfs/:id with the id in the JSON body, or use a webhook.
  const { data } = await res.json();
  console.log('queued', data.id);
} else if (res.ok) {
  await writeFile('invoice.pdf', Buffer.from(await res.arrayBuffer()));
} else {
  const { error } = await res.json();
  throw new Error(`${error.code}: ${error.message}`);
}

Which one should you use?

Use Puppeteer when you already run containers with Chrome in them, render a handful of document types, and developers own every change to the HTML.

Use an API when: You want the same template edited by your team in a browser, previewed on real data, versioned, and rendered without running Chrome yourself. You pay per render instead of per server.

Questions

Does Puppeteer work on serverless platforms?

Yes, with a Chromium build made for it (such as @sparticuz/chromium on AWS Lambda) and enough memory. Cold starts add a few seconds to the first render.

Why is my PDF missing background colours?

Chrome drops backgrounds when printing unless you pass printBackground: true, or set print-color-adjust: exact in your CSS.

Can I add page numbers?

Yes. With Puppeteer use displayHeaderFooter with a footerTemplate containing <span class="pageNumber">. In a Templater PDF template, put {{pageNumber}} and {{totalPages}} in the footer.

Try it on your own template

Start Free