HTML to PDF in Node.js, without Puppeteer
The usual Node.js answer for HTML to PDF is Puppeteer — but it pulls 200 to 400 MB of Chromium into your dependency tree and breaks in serverless environments unless you wire up a Chromium layer. pdfkitt moves the browser to the server side, so your app just makes one HTTP call and gets a PDF back. This guide covers the basic call, streaming the PDF straight to an Express response, page options, URL rendering, and error handling.
One fetch call
No SDK, no Chromium download. Send HTML, get a PDF buffer back, write it to disk or stream it to your user. Node 18 and newer have fetch built in; on older versions use undici or axios.
import fs from "node:fs/promises";
const res = await fetch("https://api.pdfkitt.dev/v1/convert", {
method: "POST",
headers: {
"Authorization": "Bearer " + process.env.PDFKITT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
html: "<h1>Invoice</h1>",
options: { page_size: "A4" },
}),
});
const pdf = Buffer.from(await res.arrayBuffer());
await fs.writeFile("invoice.pdf", pdf);Stream the PDF to an Express response
Most apps do not want the file on disk; they want to hand it to the browser. Pipe the upstream body straight into the response so a large report never sits fully in memory. The same pattern works in Fastify, Koa, and Next.js route handlers.
import express from "express";
import { Readable } from "node:stream";
const app = express();
app.get("/invoices/:id.pdf", async (req, res) => {
// Render with whatever you already use: EJS, Handlebars, React's
// renderToStaticMarkup, or a plain template string.
const html = await renderInvoiceHtml(req.params.id);
const upstream = await fetch("https://api.pdfkitt.dev/v1/convert", {
method: "POST",
headers: {
"Authorization": "Bearer " + process.env.PDFKITT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, options: { page_size: "A4" } }),
});
if (!upstream.ok) {
const { error } = await upstream.json();
return res.status(502).json({ message: error.message, request_id: error.request_id });
}
res.setHeader("Content-Type", "application/pdf");
res.setHeader("Content-Disposition", "inline; filename=invoice-" + req.params.id + ".pdf");
Readable.fromWeb(upstream.body).pipe(res);
});Swap inline for attachment in the Content-Disposition header to force a download instead of opening the PDF in a tab.
Page size, margins, and orientation
Everything about the page is set in the options object. Page size accepts A4, Letter, or Legal; margins take any CSS length; landscape and print_background are booleans. Anything you do not set falls back to A4, portrait, with backgrounds printed. For headers, footers, and page breaks, use print CSS in your HTML: @page rules and break-inside: avoid behave exactly as they do in Chrome's print preview.
{
"html": "<h1>Quarterly report</h1>",
"options": {
"page_size": "Letter",
"landscape": true,
"print_background": true,
"margins": { "top": "20mm", "right": "15mm", "bottom": "20mm", "left": "15mm" }
}
}Render a URL, with or without JavaScript
If the page already exists, send a url instead of html and pdfkitt fetches and renders it. JavaScript is off by default; turn it on per request when the page draws its content client-side. URL fetches are blocked from reaching private, loopback, and cloud-metadata addresses, so the renderer cannot be pointed at your internal network.
const res = await fetch("https://api.pdfkitt.dev/v1/convert", {
method: "POST",
headers: {
"Authorization": "Bearer " + process.env.PDFKITT_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify({
url: "https://example.com/reports/2026-q3",
javascript: true,
}),
});Handling errors and timeouts
A successful render returns status 200 with an application/pdf body. Anything else returns a JSON body with an error object containing a stable code, a human-readable message, and a request_id you can quote to support. Check res.ok before touching the body, log the request ID, and treat quota and rate-limit responses as retryable after a delay. Renders are capped at 30 seconds, so give your own HTTP client a timeout a little above that. The full list of error codes is in the API docs.
When local Puppeteer still makes sense
If you need full browser automation — interactive sessions, screenshots in many formats, or an on-prem environment where outbound API calls are not allowed — running Puppeteer or Playwright yourself is the right call. For plain HTML to PDF in a normal or serverless backend, an API removes the Chromium dependency and the cold-start pain. See how the trade-off plays out on the pdfkitt vs Puppeteer page, or jump straight to the API docs.
FAQ
Why not just use Puppeteer or Playwright in Node.js?
You can, and for interactive browser automation you should. For PDF generation the cost is operational: Puppeteer downloads a 200 to 400 MB Chromium build, needs system libraries your Docker image may not have, leaks memory under load unless you recycle pages, and breaks on serverless platforms unless you add a Chromium layer. pdfkitt keeps real Chromium rendering and removes all of that from your deployment.
Do I need an SDK or npm package?
No. The API is one HTTP endpoint that accepts JSON and returns a PDF body. Node 18 and newer ship fetch, so there is nothing to install. Any HTTP client (axios, got, undici) works the same way.
Can I render a React or Next.js component to PDF?
Yes. Render the component to a string on the server with renderToStaticMarkup, inline your CSS, and send the HTML. The React and Next.js guide covers a full route handler example.
Does the API run JavaScript inside my HTML?
Not by default. Static HTML and CSS render as-is, which is the fast and safe path. If your page needs its scripts to run, such as client-rendered charts, set javascript: true in the request and the page executes in sandboxed Chromium before capture, with a 30-second cap per render.
Does it work on Vercel, AWS Lambda, and Cloudflare Workers?
Yes. Because the browser runs on pdfkitt's side, your function only makes an outbound HTTP request, so there is no bundle-size limit or cold-start penalty to work around. See the serverless guide for platform-specific notes.
Is my HTML stored anywhere?
No. HTML is processed in memory to render the PDF and is never written to storage or logs. Only request metadata such as timing and status is recorded.
Try pdfkitt free — 1,000 PDFs/month, no credit card
Get an API key and send your first PDF in minutes.