An invoice looks like the easiest PDF you will ever generate. A header, a few rows, a total. You write it in an afternoon. Then the real requirements show up.

You need line items that loop from data, not hard-coded rows. You need tax. Then a discount. Then a second tax rate for a customer in another state. The amounts have to be formatted as currency, with the right symbol and two decimal places, right-aligned in their column so the numbers line up. A logo goes in the top corner. When an order has forty line items, the table has to break across pages without slicing a row in half, and the total has to land on the last page, not float on page one. And the finance team wants the numbers selectable and searchable, because they reconcile against them. A screenshot of a styled div will get your ticket reopened.

So the real task is not "make a PDF." It is "generate invoice PDFs reliably from a data object, with correct money math and clean page breaks." This guide shows four ways to do it in Node.js with working code: an HTML template rendered by Puppeteer (the default you should reach for), pdfkit, pdf-lib, and a hosted API. Then it covers the formatting and layout details that bite once you ship.

1. HTML template plus Puppeteer (the default)

This is the approach to reach for first. You write the invoice as HTML and CSS, which you already know how to do, and let a real browser handle layout, page breaks, and fonts. To generate invoice PDFs you build a template function, feed it a data object, and render the result with Puppeteer.

Start with the data model. Everything flows from one invoice object with an items array. Keep money in numbers, format it later.

invoice-data.js

const invoice = {

number: 'INV-1042',

date: '2026-06-29',

currency: 'USD',

from: { name: 'Acme Inc', email: '[email protected]' },

to: { name: 'Globex LLC', email: '[email protected]' },

items: [

{ name: 'API credits', qty: 5000, unit: 0.05 },

{ name: 'Priority support', qty: 1, unit: 199 },

],

taxRate: 0.08, // 8%

discount: 25, // flat, in dollars

}

Now compute the totals once, in code, and pass formatted strings to the template. Do not do math in the template. A small helper keeps the money handling honest, and we will come back to the rounding rule later.

totals.js

function round(n) { return Math.round(n * 100) / 100 }

function computeTotals(inv) {

const lines = inv.items.map(it => ({

...it, amount: round(it.qty * it.unit)

}))

const subtotal = round(lines.reduce((s, l) => s + l.amount, 0))

const taxable = round(subtotal - (inv.discount || 0))

const tax = round(taxable * (inv.taxRate || 0))

const total = round(taxable + tax)

return { lines, subtotal, tax, total }

}

Next, the template. This is plain HTML with a CSS print sheet. The header carries the logo and invoice number, a bill-to block holds the customer, and a single table renders the line items by looping over the data. The currency formatting happens in a money helper, which we define in the next section.

template.js

function renderInvoice(inv, t, money) {

const rows = t.lines.map(l => `

<tr>

  <td>${l.name}</td>

  <td class="num">${l.qty}</td>

  <td class="num">${money(l.unit)}</td>

  <td class="num">${money(l.amount)}</td>

</tr>`).join('')

return `<!doctype html><html><head><style>

@page { size: A4; margin: 18mm 16mm; }

body { font: 13px/1.5 'Inter', sans-serif; color: #111; }

.head { display: flex; justify-content: space-between; }

.logo { height: 36px; }

table { width: 100%; border-collapse: collapse; margin-top: 24px;

  page-break-inside: auto; }

tr { page-break-inside: avoid; }

th, td { padding: 8px 10px; border-bottom: 1px solid #e5e5e5; }

.num { text-align: right; font-variant-numeric: tabular-nums; }

tfoot td { border: 0; }

.total { font-weight: 700; font-size: 15px; }

</style></head><body>

<div class="head">

  <img class="logo" src="${inv.logoUrl}">

  <div><h2>Invoice ${inv.number}</h2>

  <div>${inv.date}</div></div>

</div>

<p><strong>Bill to</strong><br>${inv.to.name}<br>${inv.to.email}</p>

<table><thead><tr>

  <th>Item</th><th class="num">Qty</th>

  <th class="num">Unit</th><th class="num">Amount</th>

</tr></thead><tbody>${rows}</tbody>

<tfoot>

  <tr><td colspan="3" class="num">Subtotal</td>

    <td class="num">${money(t.subtotal)}</td></tr>

  <tr><td colspan="3" class="num">Tax</td>

    <td class="num">${money(t.tax)}</td></tr>

  <tr class="total"><td colspan="3" class="num">Total</td>

    <td class="num">${money(t.total)}</td></tr>

</tfoot></table>

</body></html>`

}

Finally, render it. This is the same Puppeteer call you would use for any document. setContent loads the string, printBackground: true keeps your background colors and borders, and the format plus margins match the @page rule.

render.js

import puppeteer from 'puppeteer'

async function invoiceToPdf(inv) {

const t = computeTotals(inv)

const money = makeMoney(inv.currency)

const html = renderInvoice(inv, t, money)

const browser = await puppeteer.launch()

const page = await browser.newPage()

await page.setContent(html, { waitUntil: 'networkidle0' })

const pdf = await page.pdf({

format: 'A4',

printBackground: true

})

await browser.close()

return pdf

}

Two CSS rules carry the page-break logic. table { page-break-inside: auto } lets a long table flow across pages instead of being shoved onto a single page or clipped. tr { page-break-inside: avoid } keeps each row whole, so you never get the top half of a line item on one page and the bottom half on the next. The browser does the hard part. You write CSS.

The tradeoff is that you are running Chromium. That means a heavy binary, real memory per render, and browser lifecycle to manage in production. We covered all of that in depth in the HTML to PDF in Node.js guide and the Puppeteer PDF generation guide. For invoices specifically, the win is that your template is just a web page, so your designer can touch it and your tax logic stays in JavaScript where you can test it.

2. pdfkit (programmatic, no browser)

If you do not want Chromium anywhere near your server, pdfkit draws PDFs directly. There is no HTML and no layout engine. You place text and lines by coordinate and advance a y-cursor down the page yourself. For a fixed invoice layout this is fast and lightweight. The cost is that you own all the layout math.

pdfkit-invoice.js

import PDFDocument from 'pdfkit'

import { createWriteStream } from 'fs'

function pdfkitInvoice(inv, t, money) {

const doc = new PDFDocument({ size: 'A4', margin: 50 })

doc.pipe(createWriteStream('invoice.pdf'))

doc.fontSize(20).text(`Invoice ${inv.number}`, 50, 50)

doc.fontSize(10).text(inv.date, 50, 78)

doc.text(`Bill to: ${inv.to.name}`, 50, 100)

// column x-positions and a moving y-cursor

const col = { name: 50, qty: 320, unit: 390, amt: 480 }

let y = 140

doc.fontSize(10)

for (const l of t.lines) {

if (y > 760) { doc.addPage(); y = 50 }

doc.text(l.name, col.name, y)

doc.text(String(l.qty), col.qty, y, { width: 50, align: 'right' })

doc.text(money(l.unit), col.unit, y, { width: 70, align: 'right' })

doc.text(money(l.amount), col.amt, y, { width: 70, align: 'right' })

y += 22

}

doc.fontSize(12).text(`Total ${money(t.total)}`, 390, y + 12, { width: 160, align: 'right' })

doc.end()

}

Notice the manual page break: when the y-cursor passes 760 points, you call doc.addPage() and reset y to the top margin. That is the part the browser did for free in the Puppeteer version. With pdfkit you also draw column headers, alignment, and any running header yourself. The text is real and selectable, which is good. But tables get tedious fast, and a layout change means moving coordinates by hand. Use pdfkit when the layout is fixed and you value a slim, browser-free dependency.

3. pdf-lib (stamp a fixed template)

pdf-lib is the right tool for a different job: you already have a designed PDF invoice template, maybe from a designer or an accounting system, and you just need to drop values into fixed positions. It loads an existing PDF and writes text at coordinates. It is also excellent for merging or appending pages, like attaching a terms sheet after the invoice.

pdf-lib-stamp.js

import { PDFDocument, StandardFonts } from 'pdf-lib'

import { readFile } from 'fs/promises'

async function stampInvoice(inv, t, money) {

const tpl = await readFile('./invoice-template.pdf')

const doc = await PDFDocument.load(tpl)

const font = await doc.embedFont(StandardFonts.Helvetica)

const page = doc.getPage(0)

// y grows upward from the bottom in pdf-lib

page.drawText(inv.number, { x: 430, y: 760, size: 12, font })

page.drawText(inv.to.name, { x: 60, y: 680, size: 11, font })

page.drawText(money(t.total), { x: 480, y: 120, size: 12, font })

return await doc.save() // Uint8Array

}

Note that pdf-lib measures y from the bottom of the page, not the top, which trips up everyone the first time. The downside for invoices is obvious: this only works when the layout is fixed. A template has space for, say, eight line items. If an order has thirty, you have no clean way to grow the table or add pages with matching styling. For dynamic-length item lists, stay with the HTML or pdfkit approaches. For stamping a known template or merging documents, pdf-lib is the cleanest option.

Don't want to run Chromium for invoices?

Send your invoice HTML to PDFBase and get a clean, selectable PDF back. Full CSS, real page breaks, zero browser ops.

4. The PDFBase API (same HTML, no Chromium)

The HTML approach is the right model. The only awkward part is running and babysitting Chromium. The API removes that. You send the exact same invoice HTML and get a PDF back. No browser binary, no memory tuning, no cold starts on your side.

pdfbase-invoice.js

import PDFBase from 'pdfbase'

const client = new PDFBase('pk_live_...')

async function invoiceToPdf(inv) {

const t = computeTotals(inv)

const money = makeMoney(inv.currency)

const html = renderInvoice(inv, t, money)

const pdf = await client.pdfs.create({

html,

format: 'A4',

printBackground: true,

output: 'buffer'

})

return pdf.data // Buffer, attach it to the email

}

It is the same renderInvoice template and the same totals helper. Only the render step changed: instead of launching a browser, you make one call. You get full Chromium rendering, so your @page margins, tabular numerals, and table page breaks all behave exactly as they did locally, and the text stays selectable.

If you want to see the output before writing any code, you can generate an invoice PDF with the free tool, paste in your HTML, and download the result. The full options, including templates and merging, are in the PDFBase docs. This is the honest pitch: it is the HTML approach without the infrastructure, nothing more magic than that.

The details that bite

The four methods get you a PDF. These details are what separate a demo from an invoice your finance team will accept.

Currency and number formatting

Do not hand-roll money formatting with toFixed and a hard-coded dollar sign. Intl.NumberFormat handles the symbol, the decimal places, the thousands separator, and the locale rules for free. Build the formatter once per invoice from its currency.

money.js

function makeMoney(currency, locale = 'en-US') {

const fmt = new Intl.NumberFormat(locale, {

style: 'currency',

currency, // 'USD', 'EUR', 'INR', 'JPY'...

})

return (n) => fmt.format(n)

}

const money = makeMoney('USD')

money(1234.5) // '$1,234.50'

makeMoney('JPY')(1234) // '¥1,234', no decimals

JPY has zero decimal places and Intl knows that. A naive toFixed(2) would have shown wrong yen. This is exactly the kind of thing that gets noticed by the one customer who pays in yen.

Right-aligning money columns

Money has to be right-aligned so the decimal points line up and the eye can scan a column. Two CSS rules do it: text-align: right on the cell, and font-variant-numeric: tabular-nums so every digit takes the same width. Without tabular figures, a column of 1s and 8s drifts slightly and looks sloppy. In pdfkit you get the same effect by passing { align: 'right', width } to doc.text, which is why every money column in the pdfkit example carried a width.

Tax rounding

Round each amount to two decimals as you go, not just at the end. Compute the line amount, round it. Compute the tax, round it. If you keep floating-point cents flowing and only round the final total, your subtotal plus tax can disagree with the printed total by a cent, and a one-cent mismatch on an invoice is the kind of thing that triggers a support ticket. That is why the round helper wraps every step in computeTotals. For high-volume billing, consider integer cents or a decimal library, but consistent two-decimal rounding covers most cases.

Multi-page totals and running headers

When the item list spills onto a second or third page, two things matter. First, the totals block must stay with the table and land after the last row, not orphan on page one. In the HTML approach, putting the totals in a tfoot and using page-break-inside: avoid on the total row keeps it intact. Second, a long invoice reads better with the invoice number repeated at the top of each page. CSS gives you this with position: running() and @page margin boxes in Chromium, or you can keep a simple header row. With pdfkit, you redraw the header inside the same block where you call addPage().

Embedding fonts

If your brand uses a specific typeface, embed it so the invoice renders the same everywhere. With the HTML approaches, link the web font in the template head and wait for networkidle0 before printing so the font has loaded. With pdfkit and pdf-lib, register the font file explicitly: doc.registerFont('Brand', './Inter.ttf') in pdfkit, or embedFont with the font bytes in pdf-lib. Do not rely on a font being installed on the server. It usually is not.

Keep the text selectable

This is the one that gets invoices rejected. If you render HTML to a canvas and drop that image into a PDF, the result looks fine but is a picture. No copy, no search, no accessibility, and finance cannot reconcile against it. All four methods here produce real text by default: Puppeteer and the API print text from the browser, pdfkit and pdf-lib draw text objects. Just avoid the html2canvas-into-a-PDF pattern. A styled invoice deserves real text, not a screenshot.

Comparison

Here is how the four approaches line up for invoice generation specifically.

Puppeteer (HTML) pdfkit pdf-lib PDFBase API
Dynamic layouts Yes (CSS handles it) Yes (manual math) No (fixed template) Yes (CSS handles it)
Selectable text Yes Yes Yes Yes
Infrastructure Chromium on server None (pure JS) None (pure JS) None (remote API)
Page breaks Automatic Manual (addPage) Manual / fixed Automatic
Best for Dynamic HTML invoices Fixed layout, no browser Stamping / merging HTML invoices at scale

Which should you use?

The short version: design the invoice in HTML, because tax logic lives in JavaScript and the layout lives in CSS where a designer can touch it. Then choose where Chromium runs.

Dynamic invoices, you run your own infra

HTML plus Puppeteer. Real CSS, automatic page breaks, full control. Budget for the Chromium binary and the browser lifecycle work.

Dynamic invoices, you don't want browser ops

The same HTML through PDFBase. Identical template and totals, one API call, no Chromium to manage. The default once volume or reliability matters.

Fixed layout, no browser allowed

pdfkit. Slim, pure-JS, real text. Worth the manual coordinate work when the layout is stable and you cannot ship Chromium.

You already have a PDF template, or need to merge

pdf-lib. Stamp values into a designed template, append a terms page, or merge a batch. Not for dynamic-length item lists.

Wrapping up

To generate invoice PDFs in Node.js, model the invoice as one data object, compute the totals in code with consistent rounding, and render an HTML template that loops the line items. That structure survives every method here. The only real decision left is where Chromium runs: on your server with Puppeteer, or on someone else's with an API. The programmatic libraries, pdfkit and pdf-lib, cover the cases where a browser is off the table or the layout is fixed.

Get the boring parts right and the invoice is done: format money with Intl.NumberFormat, right-align the columns, round every step, keep totals with the table across pages, and never ship a screenshot of text.

If you would rather not run a browser for this, you can grab 100 free credits with no card and send your invoice HTML to the API, or try the free invoice PDF generator first. The docs cover templates, merging, and batch generation for when one invoice becomes ten thousand.