From wkhtmltopdf to a modern PDF API: migration guide

A practical guide for moving away from wkhtmltopdf, covering why teams migrate, what breaks, and how to port your existing HTML templates to a modern PDF API with minimal pain.

pdfwkhtmltopdfmigrationhtml-to-pdfnode.jspython

From wkhtmltopdf to a modern PDF API: migration guide

If you've been generating PDFs on the server for more than a few years, there's a good chance wkhtmltopdf is somewhere in your stack. It's been the default tool for HTML-to-PDF conversion for over a decade. It's free, it's a single binary, and it just works — until it doesn't.

In late 2022, the wkhtmltopdf project was officially archived. No more security patches, no more bug fixes, and a rendering engine (an old fork of QtWebKit) that's frozen somewhere around 2016. If your invoices, contracts, or reports are running through it, this guide is for you.

Why migrate at all?

A few reasons teams finally pull the trigger:

  • Modern CSS doesn't render correctly. Flexbox is partially supported, CSS Grid isn't, and many @media print features behave inconsistently.
  • No web fonts via @font-face from HTTPS sources without workarounds.
  • JavaScript execution is unreliable. Charts from libraries like Chart.js or ApexCharts often don't render.
  • Security. No CVEs are being patched. If you process user-supplied HTML, this matters.
  • Operational pain. Installing it in Docker means pulling Qt dependencies, fonts, Xvfb shims, and dealing with glibc mismatches on Alpine.

If none of those bite you, you might be fine staying. If any of them do, read on.

What you're replacing

A typical wkhtmltopdf call looks like this:

bash
wkhtmltopdf \ --page-size A4 \ --margin-top 20mm \ --header-html header.html \ --footer-html footer.html \ --enable-local-file-access \ invoice.html invoice.pdf

Or from Node:

js
const wkhtmltopdf = require('wkhtmltopdf'); const fs = require('fs'); wkhtmltopdf('https://example.com/invoice/123', { pageSize: 'A4', marginTop: '20mm', }).pipe(fs.createWriteStream('invoice.pdf'));

The replacement should keep the things that worked (HTML templates, CSS, headers/footers, page numbers) and fix the things that didn't (modern CSS, web fonts, JS).

Option 1: Run headless Chromium yourself

The drop-in mental model is Puppeteer or Playwright. You get a real Chromium engine, full CSS, and reliable JS execution.

js
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' }, printBackground: true, displayHeaderFooter: true, headerTemplate: '<div style="font-size:10px; width:100%; text-align:center;">Invoice #123</div>', footerTemplate: '<div style="font-size:10px; width:100%; text-align:center;"><span class="pageNumber"></span> / <span class="totalPages"></span></div>', }); await browser.close();

This works, but you've now signed up to:

  • Keep Chromium up to date (security patches, breaking API changes).
  • Manage memory — Chromium leaks if you reuse browser instances incorrectly.
  • Bundle ~300MB of binaries into your container.
  • Handle concurrency (one page per request will OOM your server fast).
  • Configure fonts inside the container, including emoji fonts if you care about Unicode.

For some teams this is the right call. For most, the operational overhead isn't worth it.

Option 2: Use a PDF API

If your goal is just "HTML in, PDF out, reliably," an API removes the infrastructure problem entirely. This is where Kamy fits in — it runs Chromium for you, handles concurrency, and exposes a simple HTTP endpoint.

A direct port of the wkhtmltopdf example:

js
const res = await fetch('https://api.kamy.dev/v1/pdf', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.KAMY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ html: invoiceHtml, options: { format: 'A4', margin: { top: '20mm', bottom: '20mm' }, printBackground: true, header: '<div style="font-size:10px;">Invoice #123</div>', footer: '<div style="font-size:10px;"><span class="pageNumber"></span> / <span class="totalPages"></span></div>', }, }), }); const pdfBuffer = Buffer.from(await res.arrayBuffer());

Same template, same headers/footers, no Chromium in your Dockerfile.

Mapping wkhtmltopdf options

Most wkhtmltopdf flags have direct equivalents. Here are the ones that trip people up:

wkhtmltopdf Modern equivalent
--page-size A4 format: 'A4'
--margin-top 20mm margin.top: '20mm'
--header-html header (HTML string)
--footer-html footer (HTML string)
--print-media-type Default (use @media print in CSS)
`--jav