From wkhtmltopdf to a modern PDF API: migration guide

wkhtmltopdf served us well for a decade, but it's unmaintained and painful to run in production. Here's how to migrate to a modern PDF API without rewriting your templates.

pdfwkhtmltopdfmigrationnode.jsapi

From wkhtmltopdf to a modern PDF API: migration guide

If you've been generating PDFs on a backend for any length of time, you've probably shipped wkhtmltopdf. It was the default choice for years: a single binary, HTML in, PDF out. But the project has been archived since 2023, its underlying QtWebKit is over a decade old, and modern CSS features (flexbox edge cases, grid, custom properties, web fonts over HTTPS with modern TLS) either misbehave or don't work at all.

This post walks through what a migration actually looks like — the gotchas, the template changes, and the runtime differences you'll hit.

Why leave wkhtmltopdf

A quick honest list of the pain points that push teams off:

  • Unmaintained. No more security patches, no bug fixes.
  • Old rendering engine. QtWebKit predates most of the CSS you write today.
  • Fragile in containers. Font issues, missing shared libraries, headless X server hacks.
  • Binary size and cold starts. ~100MB in your Docker image, and it's not lambda-friendly.
  • No structured error output. When it fails, you get an exit code and a hope.

If those don't hurt yet, they will. Let's move on.

The two migration paths

You have two realistic options:

  1. Self-host a headless Chromium (Puppeteer, Playwright, Browserless). You own the infra.
  2. Use a hosted PDF API (Kamy, DocRaptor, PDFShift, etc.). Someone else owns the infra.

Both use a real, modern browser engine, so your CSS actually works. The difference is who runs the fleet of Chrome processes, handles font caching, and pages you at 3am when a memory leak eats a node.

For most teams generating < 100k documents a day, an API is cheaper than the engineer-hours to keep Chromium healthy. For teams with strict data residency needs or extreme volume, self-hosting can win.

Step 1: Inventory your current usage

Before you touch code, find every place wkhtmltopdf is invoked. Typical patterns:

bash
grep -rn "wkhtmltopdf" ./src grep -rn "wkhtmltopdf" ./docker grep -rn "wkhtmltopdf" ./scripts

For each call site, record:

  • Which HTML template it uses
  • What CLI flags are passed (page size, margins, headers/footers)
  • How the output is delivered (returned inline, saved to S3, emailed)

You'll map each of these to the new system.

Step 2: Fix template incompatibilities

Modern engines are stricter and more capable. Common changes:

  • Replace -webkit- prefixed properties where the unprefixed version is standard.
  • Remove page-break-* hacks; use break-before, break-after, break-inside.
  • Web fonts: use @font-face with HTTPS URLs, or embed as base64. Modern engines will actually load them.
  • Drop <script> polyfills for old browsers.

A typical print stylesheet cleanup:

css
@page { size: A4; margin: 20mm 15mm; } .invoice-table { break-inside: avoid; } .page-break { break-before: page; }

Step 3: Replace the invocation

Here's a before-and-after. The old code shells out:

ts
import { execFile } from "node:child_process"; import { promisify } from "node:util"; const exec = promisify(execFile); export async function renderInvoicePdf(html: string): Promise<Buffer> { const { stdout } = await exec("wkhtmltopdf", [ "--page-size", "A4", "--margin-top", "20mm", "--enable-local-file-access", "-", "-", ], { input: html, encoding: "buffer", maxBuffer: 50 * 1024 * 1024 }); return stdout as unknown as Buffer; }

The equivalent with a hosted API — in this case Kamy — looks like:

ts
import { kamy } from "@kamydev/sdk"; export async function renderInvoicePdf(html: string): Promise<Buffer> { const pdf = await kamy.documents.renderHtml({ html, format: "A4", margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" }, }); return Buffer.from(await pdf.arrayBuffer()); }

No binary in your image, no --enable-local-file-access footguns, no maxBuffer tuning. Same function signature, so your callers don't change.

If you prefer raw HTTP:

http
POST /v1/documents/render-html HTTP/1.1 Host: api.kamy.dev Authorization: Bearer sk_live_xxx Content-Type: application/json { "html": "<html>...</html>", "format": "A4", "margin": { "top": "20mm", "bottom": "20mm" } }

Step 4: Rework headers and footers

wkhtmltopdf's --header-html and --footer-html were quirky (separate documents, no shared state with the main HTML). Modern engines use CSS @page margin boxes or dedicated header/footer templates.

With Kamy:

ts
const pdf = await kamy.documents.renderHtml({ html: invoiceHtml, header: { html: `<div style="font-size:10px;width:100%;text-align:right;">Invoice ${id}</div>`, height: "15mm", }, footer: { html: `<div style="font-size:10px;width:100%;text-align:center;">Page