PDFs are the web's universal document format — invoices, reports, tickets, contracts, certificates. Every web application eventually needs to generate them. The question is whether to generate client-side (in the browser, no server needed) or server-side (on your backend, full CSS/font control).

This guide covers both approaches with production-ready code. Each tool has a clear sweet spot — pick the one that matches your document complexity and deployment constraints.

Client-Side PDF Generation

Client-side generation runs entirely in the browser. No server round-trip, no backend dependency. The tradeoff: limited layout capabilities compared to server-side tools.

jsPDF: Programmatic PDF Construction

jsPDF builds PDFs by drawing elements onto a coordinate-based canvas. You position text, images, lines, and rectangles explicitly.

import { jsPDF } from 'jspdf';

function generateInvoice(data) {
  const doc = new jsPDF();
  
  // Header
  doc.setFontSize(24);
  doc.text('INVOICE', 20, 30);
  
  doc.setFontSize(10);
  doc.text(`Invoice #: ${data.number}`, 20, 45);
  doc.text(`Date: ${data.date}`, 20, 52);
  doc.text(`Due: ${data.dueDate}`, 20, 59);
  
  // Customer info
  doc.setFontSize(12);
  doc.text('Bill To:', 120, 45);
  doc.setFontSize(10);
  doc.text(data.customer.name, 120, 52);
  doc.text(data.customer.address, 120, 59);
  
  // Line items table
  let y = 85;
  doc.setFontSize(10);
  doc.setFont(undefined, 'bold');
  doc.text('Description', 20, y);
  doc.text('Qty', 120, y);
  doc.text('Price', 145, y);
  doc.text('Total', 175, y);
  doc.line(20, y + 2, 195, y + 2);
  
  doc.setFont(undefined, 'normal');
  data.items.forEach(item => {
    y += 10;
    doc.text(item.description, 20, y);
    doc.text(String(item.qty), 120, y);
    doc.text(`$${item.price.toFixed(2)}`, 145, y);
    doc.text(`$${(item.qty * item.price).toFixed(2)}`, 175, y);
  });
  
  // Total
  y += 15;
  doc.line(145, y - 3, 195, y - 3);
  doc.setFont(undefined, 'bold');
  doc.text('Total:', 145, y);
  doc.text(`$${data.total.toFixed(2)}`, 175, y);
  
  return doc.output('blob');
}

Best for: Invoices, receipts, certificates, shipping labels — documents with predictable, formulaic layouts. jsPDF excels when you know exactly where every element goes.

Limitations: No CSS support. No automatic text wrapping (you must handle line breaks). No HTML rendering. Custom fonts require embedding (adds 100-500KB per font). Complex tables need manual coordinate calculation.

html2pdf.js: HTML to PDF in the Browser

html2pdf.js combines html2canvas (renders HTML to canvas) with jsPDF (canvas to PDF). It captures what the browser renders, so CSS styling works.

import html2pdf from 'html2pdf.js';

// Convert a DOM element to PDF
const element = document.getElementById('invoice-container');

html2pdf()
  .set({
    margin: [10, 10, 10, 10],
    filename: 'invoice.pdf',
    image: { type: 'jpeg', quality: 0.95 },
    html2canvas: {
      scale: 2,           // 2x resolution for sharp text
      useCORS: true,      // Load cross-origin images
      letterRendering: true
    },
    jsPDF: {
      unit: 'mm',
      format: 'a4',
      orientation: 'portrait'
    }
  })
  .from(element)
  .save();

Best for: Converting visible page content to PDF — reports, dashboards, printable views. Whatever the browser renders, html2pdf.js captures.

Limitations: Text is rasterized (not selectable in the PDF). Multi-page documents handle page breaks poorly. Performance degrades with complex DOMs. Cross-origin images need CORS headers. The output is essentially a screenshot embedded in a PDF, not a true text PDF.

Server-Side PDF Generation

Server-side tools have full access to fonts, CSS, and rendering engines. They produce higher-quality PDFs with selectable text, proper page breaks, and complex layouts.

Puppeteer / Playwright: Chrome-Quality Rendering

Puppeteer (or Playwright) launches a headless Chromium browser and renders your HTML with the same engine that powers Chrome. The result is pixel-perfect, CSS-compliant PDF output.

import puppeteer from 'puppeteer';

async function generatePDF(htmlContent) {
  const browser = await puppeteer.launch({
    headless: 'new',
    args: ['--no-sandbox', '--disable-setuid-sandbox']
  });
  const page = await browser.newPage();
  
  // Load HTML content
  await page.setContent(htmlContent, {
    waitUntil: 'networkidle0'
  });
  
  // Generate PDF
  const pdf = await page.pdf({
    format: 'A4',
    margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
    printBackground: true,         // Include background colors
    displayHeaderFooter: true,
    headerTemplate: '<span></span>',
    footerTemplate: `
      <div style="font-size: 9px; width: 100%; text-align: center; color: #666;">
        Page <span class="pageNumber"></span> of <span class="totalPages"></span>
      </div>`,
  });
  
  await browser.close();
  return pdf; // Buffer
}

// Express endpoint
app.post('/api/generate-pdf', async (req, res) => {
  const html = renderInvoiceTemplate(req.body);
  const pdf = await generatePDF(html);
  res.setHeader('Content-Type', 'application/pdf');
  res.setHeader('Content-Disposition', 'attachment; filename="invoice.pdf"');
  res.send(pdf);
});

Best for: Complex documents with modern CSS (Flexbox, Grid, custom fonts, background images). Invoices, reports, contracts — anything where pixel-perfect rendering matters. Puppeteer is the gold standard for HTML-to-PDF when quality is priority.

Limitations: Heavy dependency (~300MB Chromium binary). Cold start is 1-3 seconds per browser launch (reuse browser instances in production). Memory-intensive (200-400MB per browser process). Overkill for simple documents.

WeasyPrint: CSS-Based PDF (Python)

WeasyPrint is a Python library that converts HTML+CSS to PDF using its own rendering engine (no browser needed). It supports a substantial subset of CSS, including Flexbox, page breaks, and @media print.

# Install: pip install weasyprint
import weasyprint

# From HTML string
html = weasyprint.HTML(string=html_content)
pdf = html.write_pdf()

# From URL
pdf = weasyprint.HTML(url='https://example.com/invoice').write_pdf()

# With custom stylesheets
css = weasyprint.CSS(string='''
  @page { size: A4; margin: 2cm; }
  @page { @bottom-center { content: "Page " counter(page) " of " counter(pages); } }
''')
pdf = weasyprint.HTML(string=html_content).write_pdf(stylesheets=[css])

Best for: Python backends that need reliable PDF generation without browser dependencies. Lighter than Puppeteer (~50MB vs ~300MB), faster startup, and excellent CSS print support.

Limitations: No JavaScript execution (can't render React/Vue components). CSS Grid support is partial. Some advanced CSS features (backdrop-filter, clip-path) are unsupported. Python-only.

wkhtmltopdf: Legacy but Reliable

wkhtmltopdf uses an older WebKit engine to convert HTML to PDF. It's been the workhorse of server-side PDF generation for a decade.

# Command line
wkhtmltopdf --page-size A4 --margin-top 20mm --margin-bottom 20mm \
  --enable-local-file-access input.html output.pdf

# From URL
wkhtmltopdf https://example.com/invoice output.pdf

# With header/footer
wkhtmltopdf --header-html header.html --footer-html footer.html \
  input.html output.pdf

Best for: Simple to moderate HTML documents, especially in Docker/CI environments where installing Chromium is inconvenient. Available as a single binary on Linux, macOS, and Windows.

Limitations: Uses a Qt WebKit fork (not Chrome's engine), so modern CSS (Flexbox, Grid, custom properties) renders incorrectly or not at all. The project is effectively unmaintained. For new projects, prefer Puppeteer or WeasyPrint.

Tool Comparison

ToolEnvironmentCSS SupportText SelectableCustom FontsSize
jsPDFBrowserNone (manual layout)YesEmbedded (manual)~300KB
html2pdf.jsBrowserWhatever browser rendersNo (rasterized)Whatever browser has~500KB
PuppeteerNode.jsFull Chrome CSSYes@font-face works~300MB (Chromium)
WeasyPrintPythonGood (Flexbox, print CSS)Yes@font-face works~50MB
wkhtmltopdfCLI/anyLimited (old WebKit)YesSystem fonts~50MB

A well-designed print stylesheet can serve double duty: beautiful printed output AND PDF generation. Puppeteer uses the print media type when generating PDFs.

<link rel="stylesheet" href="print.css" media="print">

/* print.css */
@page {
  size: A4;
  margin: 2cm;
}

@page :first {
  margin-top: 3cm; /* Extra space on first page for header */
}

body {
  font-family: 'Georgia', serif;
  font-size: 11pt;
  line-height: 1.5;
  color: #000;
}

/* Hide navigation, footer, interactive elements */
nav, footer, .no-print, button {
  display: none !important;
}

/* Page break control */
h1, h2 {
  break-after: avoid;   /* Don't break right after a heading */
}

table, figure {
  break-inside: avoid;  /* Keep tables and figures on one page */
}

/* Force page break before specific sections */
.chapter {
  break-before: page;
}

The key @page rules:

  • break-before: page — Force a page break before an element
  • break-after: avoid — Prevent a page break after an element (keep heading with content)
  • break-inside: avoid — Prevent an element from splitting across pages
  • orphans: 3 / widows: 3 — Minimum lines at bottom/top of a page

Common PDF Generation Gotchas

Fonts: If your PDF has missing or wrong fonts, the tool is falling back to system fonts. With Puppeteer, ensure fonts are installed on the server or loaded via @font-face in your HTML. With WeasyPrint, install font packages (apt install fonts-liberation). Test on a clean server — your development machine likely has fonts that the production server doesn't.

Images: Relative image paths break when generating from HTML strings. Always use absolute URLs or data URIs for images in PDF templates. With Puppeteer, use waitUntil: 'networkidle0' to ensure all images are loaded before rendering.

Page breaks: The most common layout problem. Tables, images, and blocks split awkwardly across pages. Use break-inside: avoid aggressively. Test with realistic data — an invoice that looks perfect with 5 items might break horribly with 50.

PDF/A for archival: PDF/A is an ISO standard for long-term document preservation. It requires embedded fonts, no JavaScript, and specific metadata. Generating PDF/A is complex — most tools need plugins or post-processing. If you need PDF/A compliance (government, legal, archival), use a dedicated library like Apache PDFBox or a commercial tool.

Form-fillable PDFs: None of the tools above generate fillable PDF forms natively. Use pdf-lib (JavaScript) to add form fields to existing PDFs, or use a PDF form library specific to your backend language.

PDF generation technology is mature but fragmented. The right tool depends on where you generate (browser vs server), what CSS features you need, and how complex your layouts are. For most web applications, Puppeteer is the best default — it renders exactly what Chrome would render, which means your HTML templates work identically in the browser and as PDFs.

For simpler needs, ChangeThisFile handles format conversion directly: HTML to PDF, DOCX to PDF, JPG to PDF, and PNG to PDF — all without installing any tools.