Get started

Generate PDFs from HTML templates with Node.js

Build a Node.js PDF generator using Handlebars templates and Playwright. Covers dynamic data, styling, fonts, Docker, and when a PDF API makes more sense.

benoitdedFebruary 18, 20269 min read

Generating PDFs programmatically sounds simple until you actually try it in production. Consistent rendering across environments, proper font handling, dynamic data injection, Docker dependencies, concurrency: it adds up fast.

This guide covers how to do it right with Playwright, then explains when managing the browser yourself stops being worth it.

The approaches

ApproachHow it worksBest for
Headless browser (Playwright/Puppeteer)Renders HTML in Chromium, exports PDFPixel-perfect HTML rendering
PDF library (pdf-lib, PDFKit)Programmatic PDF constructionSimple docs, low dependencies
PDF API (PDF4.dev)Send HTML, get PDF backProduction workloads without the infrastructure

Option 1: Playwright

Playwright gives you the most faithful HTML-to-PDF conversion. Anything that renders in a browser becomes a PDF.

npm install playwright
npx playwright install chromium

Basic conversion

import { chromium } from 'playwright';
 
async function htmlToPdf(html) {
  const browser = await chromium.launch();
  const page = await browser.newPage();
 
  await page.setContent(html, { waitUntil: 'networkidle' });
 
  const pdf = await page.pdf({
    format: 'A4',
    margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
    printBackground: true,
  });
 
  await browser.close();
  return pdf; // Buffer
}
 
const html = `
  <html>
    <head>
      <style>
        body { font-family: Arial, sans-serif; }
        h1 { color: #333; }
      </style>
    </head>
    <body>
      <h1>Invoice #001</h1>
      <p>Total: $1,500.00</p>
    </body>
  </html>
`;
 
const pdf = await htmlToPdf(html);
require('fs').writeFileSync('output.pdf', pdf);

Dynamic data with Handlebars

Handlebars is the standard template engine for this. It's simple, fast, and keeps your templates clean:

npm install handlebars
import Handlebars from 'handlebars';
import { chromium } from 'playwright';
 
const template = Handlebars.compile(`
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body {
      font-family: 'Helvetica Neue', Arial, sans-serif;
      font-size: 14px;
      color: #333;
      padding: 40px;
    }
    .header { display: flex; justify-content: space-between; margin-bottom: 40px; }
    .company-name { font-size: 24px; font-weight: 700; }
    .invoice-number { font-size: 14px; color: #666; }
    table { width: 100%; border-collapse: collapse; margin: 24px 0; }
    th { background: #f5f5f5; padding: 10px; text-align: left; font-size: 12px; }
    td { padding: 10px; border-bottom: 1px solid #eee; }
    .total-row td { font-weight: 700; font-size: 16px; border-top: 2px solid #333; }
  </style>
</head>
<body>
  <div class="header">
    <div>
      <div class="company-name">{{company.name}}</div>
      <div>{{company.address}}</div>
    </div>
    <div>
      <div class="invoice-number">Invoice #{{invoice.number}}</div>
      <div>Date: {{invoice.date}}</div>
      <div>Due: {{invoice.dueDate}}</div>
    </div>
  </div>
 
  <table>
    <thead>
      <tr>
        <th>Description</th>
        <th>Qty</th>
        <th>Unit Price</th>
        <th>Total</th>
      </tr>
    </thead>
    <tbody>
      {{#each items}}
      <tr>
        <td>{{description}}</td>
        <td>{{quantity}}</td>
        <td>{{unitPrice}}</td>
        <td>{{total}}</td>
      </tr>
      {{/each}}
    </tbody>
    <tfoot>
      <tr class="total-row">
        <td colspan="3">Total</td>
        <td>{{invoice.total}}</td>
      </tr>
    </tfoot>
  </table>
</body>
</html>
`);
 
async function generateInvoice(data) {
  const html = template(data);
 
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setContent(html, { waitUntil: 'networkidle' });
 
  const pdf = await page.pdf({
    format: 'A4',
    printBackground: true,
    margin: { top: '10mm', bottom: '10mm', left: '10mm', right: '10mm' },
  });
 
  await browser.close();
  return pdf;
}
 
const pdf = await generateInvoice({
  company: { name: 'Acme Corp', address: '123 Main St, San Francisco, CA 94105' },
  invoice: {
    number: 'INV-2026-001',
    date: 'March 1, 2026',
    dueDate: 'March 31, 2026',
    total: '$3,450.00',
  },
  items: [
    { description: 'Web Development', quantity: 30, unitPrice: '$100.00', total: '$3,000.00' },
    { description: 'Design Review', quantity: 3, unitPrice: '$150.00', total: '$450.00' },
  ],
});

Reusing the browser (production pattern)

Launching a new browser per request is expensive. Each chromium.launch() takes ~200ms. Reuse a singleton:

import { chromium, Browser } from 'playwright';
 
let browser: Browser | null = null;
 
async function getBrowser() {
  if (!browser || !browser.isConnected()) {
    browser = await chromium.launch({
      args: ['--no-sandbox', '--disable-dev-shm-usage'], // required in Docker
    });
  }
  return browser;
}
 
async function renderPdf(html: string): Promise<Buffer> {
  const b = await getBrowser();
  const page = await b.newPage();
 
  try {
    await page.setContent(html, { waitUntil: 'networkidle' });
    const pdf = await page.pdf({ format: 'A4', printBackground: true });
    return pdf;
  } finally {
    await page.close(); // always close the page, keep the browser
  }
}

Custom fonts

Embed fonts using @font-face with base64-encoded woff2 files for reliable rendering:

import fs from 'fs';
 
const fontBase64 = fs.readFileSync('Inter.woff2').toString('base64');
 
const html = `
<html>
<head>
  <style>
    @font-face {
      font-family: 'Inter';
      src: url('data:font/woff2;base64,${fontBase64}') format('woff2');
      font-weight: normal;
    }
    body { font-family: 'Inter', sans-serif; }
  </style>
</head>
<body>...</body>
</html>
`;

Or load from a CDN by waiting for fonts to load:

await page.setContent(html, { waitUntil: 'networkidle' });
await page.evaluate(() => document.fonts.ready);

Option 2: Puppeteer

Puppeteer uses the same approach as Playwright, with slightly different syntax:

npm install puppeteer
import puppeteer from 'puppeteer';
 
const browser = await puppeteer.launch();
const page = await browser.newPage();
 
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdf = await page.pdf({
  format: 'A4',
  margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
  printBackground: true,
});
 
await browser.close();

Playwright is generally preferred for new projects: better async model, wider browser support, and more active maintenance.

This works. Until it doesn't.

The code above is correct for a prototype or internal tool generating a handful of PDFs. In production, a few things start to hurt.

Docker images balloon. Chromium pulls in 20+ system libraries (libatk-bridge2.0-0, libcairo2, libnss3, xvfb, and more). Your Docker image gains 300-400 MB just for the browser. Every deploy is slower. Every container is larger.

Concurrency is your problem. If ten users request a PDF at the same time, ten pages compete for the same browser instance. Without a page pool, requests queue. With a pool (see generic-pool below), you're now maintaining infrastructure code.

Serverless functions are painful. Lambda and Vercel have size limits. The workaround, @sparticuz/chromium, works but adds complexity, cold start latency, and its own maintenance surface.

The singleton crashes, and it's your on-call. Chromium can disconnect due to OOM, signals, or crashes. Your singleton pattern needs crash detection, restart logic, and health checks. That's more code.

None of this is impossible to solve. But for every PDF you generate, you're also now operating a headless browser service.

Option 3: PDF4.dev API

If you'd rather not manage Chromium in production, PDF4.dev handles the rendering, scaling, and infrastructure. You send HTML (or a template ID), you get a PDF back.

async function generatePdf(templateId, data) {
  const response = await fetch('https://pdf4.dev/api/v1/render', {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${process.env.PDF4_API_KEY}`,
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ template_id: templateId, data }),
  });
 
  if (!response.ok) {
    const error = await response.json();
    throw new Error(error.error.message);
  }
 
  return Buffer.from(await response.arrayBuffer());
}
 
const pdf = await generatePdf('invoice', {
  company_name: 'Acme Corp',
  invoice_number: 'INV-001',
  total: '$1,500.00',
});

That's it. No browser singleton, no Docker dependencies, no page pool. The API runs Playwright internally with a warm browser pool, so typical response time is ~300ms.

Or with raw HTML instead of a saved template:

const response = await fetch('https://pdf4.dev/api/v1/render', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${process.env.PDF4_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    html: '<html><body><h1>Hello {{name}}</h1></body></html>',
    data: { name: 'World' },
    format: { preset: 'a4' },
  }),
});

Comparing the two approaches side by side:

Self-hosted PlaywrightPDF4.dev API
SetupInstall + Docker config + Chromium depsfetch() + API key
ConcurrencyManual page poolHandled
Serverless@sparticuz/chromium workaroundWorks out of the box
Docker image size+300-400 MBNo change
CrashesYour on-callNot your problem
Rendering qualityFull ChromiumFull Chromium (same engine)

The rendering output is identical: both use Chromium. The difference is who manages the browser.

PDF4.dev has a free tier. Get an API key and generate your first PDF in minutes, no Docker required.

Serving PDFs in an Express API

Whether you use self-hosted Playwright or the API, the Express layer is the same:

import express from 'express';
 
const app = express();
app.use(express.json());
 
app.post('/generate-invoice', async (req, res) => {
  try {
    const { company, items } = req.body;
    const html = template({ company, items });
    const pdf = await renderPdf(html); // swap for generatePdf() to use the API
 
    res.set({
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="invoice-${Date.now()}.pdf"`,
      'Content-Length': pdf.length,
    });
    res.send(pdf);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});
 
app.listen(3000);

Running in Docker (self-hosted path)

If you go the self-hosted route, Playwright in Docker needs the right base image:

FROM node:20-slim
 
# Playwright dependencies
RUN apt-get update && apt-get install -y \
    chromium \
    fonts-liberation \
    libatk-bridge2.0-0 \
    libcairo2 \
    libcups2 \
    libdrm2 \
    libgbm1 \
    libglib2.0-0 \
    libnss3 \
    libpango-1.0-0 \
    libxcomposite1 \
    libxdamage1 \
    libxfixes3 \
    libxrandr2 \
    libxss1 \
    xvfb \
    && rm -rf /var/lib/apt/lists/*
 
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
ENV PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium
 
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
 
CMD ["node", "server.js"]

And launch Chromium with sandbox disabled:

const browser = await chromium.launch({
  executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
  args: ['--no-sandbox', '--disable-setuid-sandbox'],
});

Performance tips (self-hosted)

Reuse the browser. Launching Chromium is ~200ms. Use a singleton.

Parallelize with a page pool if you have high throughput:

import genericPool from 'generic-pool';
 
const pool = genericPool.createPool({
  create: async () => {
    const b = await getBrowser();
    return b.newPage();
  },
  destroy: async (page) => page.close(),
}, { min: 2, max: 10 });
 
async function renderWithPool(html) {
  const page = await pool.acquire();
  try {
    await page.setContent(html, { waitUntil: 'networkidle' });
    return await page.pdf({ format: 'A4' });
  } finally {
    await pool.release(page);
  }
}

Minimize external requests. Inline CSS, base64-encode fonts and images. waitUntil: 'networkidle' waits for all external requests to complete.

Use waitUntil: 'domcontentloaded' when possible. Faster than networkidle if you've inlined all assets.

FAQ

Puppeteer or Playwright?

Both work. Playwright is more actively maintained, has a better TypeScript API, and supports more browsers. Use Playwright for new projects.

Can I use CSS frameworks like Tailwind?

Yes. Include the Tailwind CDN script in the HTML, or generate and inline the CSS output. Tailwind's JIT mode generates CSS from class names, so you need the full stylesheet inlined, not just the CDN build.

What about page breaks?

Use CSS page-break-before, page-break-after, or break-before/break-after:

.chapter { page-break-before: always; }
.no-break { page-break-inside: avoid; }

How do I add headers and footers?

Playwright supports headerTemplate and footerTemplate in the pdf() options:

await page.pdf({
  format: 'A4',
  displayHeaderFooter: true,
  headerTemplate: '<div style="font-size:10px; text-align:center; width:100%">My Company</div>',
  footerTemplate: '<div style="font-size:10px; text-align:center; width:100%"><span class="pageNumber"></span></div>',
});

Is this approach scalable?

For moderate loads (up to ~100 PDFs/hour), a single server with the browser pool pattern handles it fine. Beyond that, managing concurrency, memory limits, and crash recovery becomes a real engineering project. That's the point where an API makes the trade-off worthwhile.

Free tools mentioned:

Html To PdfTry it free

Start generating PDFs

Build PDF templates with a visual editor. Render them via API from any language in ~300ms.