You can generate a PDF from HTML in Node.js without running Chrome yourself: POST the HTML to the HTML to PDF API and download the file from the downloadURL it returns. A real browser does the rendering, so print CSS works: we tested repeated table headers, rows that don’t split across pages, Google Fonts and CSS page numbers, and all of them came out right. A two-page invoice took about 4 seconds. Two things didn’t work the way you might assume, and both are below the code.
The whole thing in one file
This builds an invoice from data, renders it and saves the PDF.
// invoice.js: HTML invoice -> PDF with the APIVerve HTML to PDF API (Node 18+)
const { writeFile } = require('node:fs/promises');
const KEY = process.env.APIVERVE_API_KEY;
// Customer data goes into HTML, so escape it. The renderer is a real browser.
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const money = (n) => n.toLocaleString('en-US', { style: 'currency', currency: 'USD' });
function invoiceHtml(inv) {
const rows = inv.items.map((it) => `
<tr><td>${esc(it.description)}</td><td class="n">${it.qty}</td>
<td class="n">${money(it.price)}</td><td class="n">${money(it.qty * it.price)}</td></tr>`).join('');
const total = inv.items.reduce((sum, it) => sum + it.qty * it.price, 0);
return `<!doctype html><html><head><meta charset="utf-8">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap" rel="stylesheet">
<style>
@page { margin: 0.6in; @bottom-right { content: "Page " counter(page) " of " counter(pages); font: 9px Inter, sans-serif; color: #888; } }
body { font: 11px/1.5 Inter, sans-serif; color: #111; }
h1 { font-size: 20px; margin: 0 0 4px; }
table { width: 100%; border-collapse: collapse; margin-top: 24px; }
th, td { padding: 6px 8px; border-bottom: 1px solid #e5e5e5; text-align: left; }
thead { display: table-header-group; } /* repeat the header on every page */
tr { break-inside: avoid; } /* never split a row across pages */
.n { text-align: right; white-space: nowrap; }
.total td { font-weight: 600; border-bottom: none; }
</style></head><body>
<h1>Invoice ${esc(inv.number)}</h1>
<div>${esc(inv.customer)} · Issued ${esc(inv.date)}</div>
<table>
<thead><tr><th>Description</th><th class="n">Qty</th><th class="n">Price</th><th class="n">Amount</th></tr></thead>
<tbody>${rows}<tr class="total"><td colspan="3">Total</td><td class="n">${money(total)}</td></tr></tbody>
</table>
</body></html>`;
}
async function renderPdf(html) {
const res = await fetch('https://api.apiverve.com/v1/htmltopdf', {
method: 'POST',
headers: { 'x-api-key': KEY, 'Content-Type': 'application/json' },
body: JSON.stringify({ html }),
signal: AbortSignal.timeout(30000),
});
const body = await res.json();
if (!res.ok || body.status !== 'ok') throw new Error(`htmltopdf ${res.status}: ${JSON.stringify(body.error)}`);
// The download link expires after about 48 hours. Fetch the file now and keep it yourself.
const pdf = await fetch(body.data.downloadURL, { signal: AbortSignal.timeout(30000) });
if (!pdf.ok) throw new Error(`download ${pdf.status}`);
return Buffer.from(await pdf.arrayBuffer());
}
module.exports = { invoiceHtml, renderPdf };
if (require.main === module) {
(async () => {
const inv = {
number: 'INV-1042', customer: 'Acme <Widgets> & Co', date: '2026-10-23',
items: Array.from({ length: 60 }, (_, i) => ({ description: `Support hours, week ${i + 1}`, qty: (i % 5) + 1, price: 85 })),
};
const t = Date.now();
const pdf = await renderPdf(invoiceHtml(inv));
await writeFile('INV-1042.pdf', pdf);
console.log(`INV-1042.pdf: ${pdf.length} bytes in ${Date.now() - t} ms`);
})();
}
$ node invoice.js
INV-1042.pdf: 68107 bytes in 3862 ms
Sixty line items came out as two pages. The column headers appear at the top of page two, no row is cut in half at the page break, the customer name prints as the literal text Acme <Widgets> & Co rather than being read as a tag, and “Page 1 of 2” sits in the bottom-right corner of each page.
The API’s response, before the download, looks like this (signature shortened):
{
"status": "ok",
"error": null,
"data": {
"marginLeft": "0.4in", "marginRight": "0.4in", "marginTop": "0.4in", "marginBottom": "0.4in",
"landscape": false,
"pdfName": "e0f4cd59-0cb1-4761-b620-79a64fc3a124.pdf",
"expires": 1792953893316,
"downloadURL": "https://storage.googleapis.com/apiverve/APIData/htmltopdf/e0f4cd59-....pdf?GoogleAccessId=...&Expires=1792953893&Signature=..."
}
}
expires is in milliseconds. That one works out to October 25, 2026 at 18:44 UTC, 48 hours after the render. The link is signed and stops working at that time, so download the file right away and store it yourself.
What the renderer honours
We wrote one small HTML document per question people ask about HTML-to-PDF, rendered each through the API and inspected the PDFs.
| Test | Result |
|---|---|
thead { display: table-header-group } on a 200-row table | Header repeated on all 6 pages |
tr { break-inside: avoid } | No row split across a page break |
Google Fonts via <link> | Inter rendered and embedded |
| Remote image over HTTPS | Embedded |
<a href> | Clickable link kept in the PDF |
@page { @bottom-center { content: counter(page) " of " counter(pages) } } | “Page 1 of 3”, “Page 2 of 3”, “Page 3 of 3” |
@page { margin: 0.75in } | Honoured |
| JavaScript that runs as the page loads | Its output is in the PDF |
| JavaScript on a 2-second timer | Its output is not in the PDF |
@page { size: letter } | Ignored: the page is A4 |
| No font specified | Liberation Serif (a Times lookalike) |
CSS page numbers work too. For years you needed a headless browser’s header and footer templates for them; current Chromium supports the CSS @page margin boxes directly, and that’s what the invoice uses.
Two things to know before you rely on it
The page is always A4. The API doesn’t take a page-size option, and Chromium ignores @page { size } unless the caller asks it not to, which ours doesn’t. A4 is 8.27 by 11.69 inches against Letter’s 8.5 by 11, so a Letter-designed layout gets slightly narrower and slightly taller. For most invoices nobody notices; if you print onto pre-cut Letter stationery, you will. Until there’s a page-size option, design to A4 if the exact size matters.
The renderer doesn’t wait for timers. It waits for the page to load and the network to go quiet, then prints. Content your scripts add during loading is there; content added by a setTimeout, or after a slow fetch to your API, may not be. Build the HTML with the data already in it, the way the invoice does, rather than shipping a page that fetches its own data.
Serving it from Express
Rendering takes about 3 seconds, so don’t do it on every request for a document that doesn’t change. This route renders an invoice the first time it’s asked for and serves the saved file after that.
// server.js: serve invoices as PDFs, rendering each one once
const express = require('express');
const { mkdir, readFile, writeFile } = require('node:fs/promises');
const { invoiceHtml, renderPdf } = require('./invoice.js');
const app = express();
const getInvoice = async (id) => ({ // stand-in for your database
number: id, customer: 'Acme Widgets', date: '2026-10-23',
items: [{ description: 'API integration', qty: 12, price: 85 }],
});
app.get('/invoices/:id.pdf', async (req, res) => {
const id = req.params.id.replace(/[^A-Za-z0-9-]/g, '');
const path = `invoices/${id}.pdf`;
let pdf = await readFile(path).catch(() => null);
if (!pdf) {
try {
pdf = await renderPdf(invoiceHtml(await getInvoice(id)));
} catch (err) {
console.error(err.message);
return res.status(502).send('Could not generate the invoice. Try again in a minute.');
}
await mkdir('invoices', { recursive: true });
await writeFile(path, pdf); // an issued invoice doesn't change, so render it once
}
res.type('application/pdf').set('Content-Disposition', `inline; filename="${id}.pdf"`).send(pdf);
});
app.listen(3000, () => console.log('listening on 3000'));
Two requests for the same invoice:
$ curl -s -o INV-1043.pdf -w "%{http_code} %{content_type} %{size_download} bytes %{time_total}s\n" localhost:3000/invoices/INV-1043.pdf
200 application/pdf 58055 bytes 3.495350s
$ curl -s -o INV-1043.pdf -w "%{http_code} %{content_type} %{size_download} bytes %{time_total}s\n" localhost:3000/invoices/INV-1043.pdf
200 application/pdf 58055 bytes 0.011328s
The local folder is for the example; in production put the file wherever your app keeps files, such as S3 or Cloud Storage. The sanitised id keeps a request like /invoices/../../etc/passwd.pdf from walking out of that folder.
How long a render takes
We rendered the 6-page, 200-row table ten times in a row: the median was 2.8 seconds and the slowest 4.4. Our own monitoring agrees: Chromium-based renders take about 3 seconds warm, including saving the file. Most of that is starting the page and printing it, so a one-line document isn’t much faster than a long one: our one-line test documents took 2.5 to 3.9 seconds.
That’s fine for an invoice someone clicks “download” on, and too slow for anything rendered in a loop on the request path. Generate documents when the data changes (an order is placed, a month closes), store them, and serve the stored copy.
And if all you have is a folder of HTML files to convert once, with no server involved, EvlarSoft’s HTML to PDF app turns the whole stack into PDFs on Windows.