Generate an invoice PDF in the browser
Build and download a paginated invoice PDF client-side with jsPDF, using a lazy import and manual table layout.
React / Next.jsUtilitiespdfjspdfinvoicetypescript
Code
export type InvoiceItem = { description: string; qty: number; price: number };
export type Invoice = { number: string; client: string; currency: string; items: InvoiceItem[]; notes?: string };
export async function downloadInvoicePdf(inv: Invoice) {
const { jsPDF } = await import('jspdf'); // loaded only when the button is clicked
const doc = new jsPDF({ unit: 'mm', format: 'a4' });
const money = new Intl.NumberFormat('en', { style: 'currency', currency: inv.currency });
const left = 15;
const right = 195;
const bottom = 270;
doc.setFontSize(20).text('Invoice', left, 22);
doc.setFontSize(10).setTextColor(100);
doc.text(`No. ${inv.number}`, right, 18, { align: 'right' });
doc.text(new Date().toLocaleDateString('en'), right, 24, { align: 'right' });
doc.setTextColor(0).text(`Bill to: ${inv.client}`, left, 36);
let y = 50;
const heading = () => {
doc.setFont('helvetica', 'bold').setFontSize(10);
doc.text('Description', left, y);
doc.text('Qty', 120, y, { align: 'right' });
doc.text('Price', 150, y, { align: 'right' });
doc.text('Amount', right, y, { align: 'right' });
doc.line(left, y + 2, right, y + 2);
doc.setFont('helvetica', 'normal');
y += 8;
};
heading();
let totalCents = 0;
for (const item of inv.items) {
const lines: string[] = doc.splitTextToSize(item.description, 90);
const rowH = lines.length * 5 + 2;
if (y + rowH > bottom) {
doc.addPage(); // continue the table on a new page
y = 20;
heading();
}
const cents = Math.round(item.qty * item.price * 100);
totalCents += cents;
doc.text(lines, left, y);
doc.text(String(item.qty), 120, y, { align: 'right' });
doc.text(money.format(item.price), 150, y, { align: 'right' });
doc.text(money.format(cents / 100), right, y, { align: 'right' });
y += rowH;
}
if (y + 20 > bottom) {
doc.addPage();
y = 20;
}
doc.line(130, y, right, y);
doc.setFont('helvetica', 'bold').setFontSize(12);
doc.text(`Total ${money.format(totalCents / 100)}`, right, y + 8, { align: 'right' });
if (inv.notes) doc.setFont('helvetica', 'normal').setFontSize(9).text(doc.splitTextToSize(inv.notes, 120), left, y + 8);
doc.save(`${inv.number}.pdf`);
}
Needs: jspdf@^2.5.1
How it works
What it solves
Letting a visitor download an invoice usually means a server round trip and a PDF service. For simple documents, jsPDF can draw the whole thing in the browser: no upload, no queue, and the data never leaves the device.
How it works
downloadInvoicePdf dynamically imports jsPDF, so the roughly 300 KB library is only fetched when the user clicks download rather than bundled into the page. It lays out the header, a bill-to block and a table of line items using absolute coordinates in millimetres on an A4 page.
The line-item loop tracks a running y position. Before drawing each row it checks whether the row would overrun the bottom margin and, if so, calls addPage and redraws the column headings. Descriptions go through splitTextToSize, which wraps long text to the column width and tells you how many lines it used, so the row height adapts. Money is formatted with Intl.NumberFormat, and totals are right-aligned with the align: 'right' option.
Adapting it
- Add a logo with
doc.addImagefrom a data URL. - Switch the page size to
'letter'for US documents. - Use the
jspdf-autotableplugin if you need richer tables.
Pitfalls
- The built-in fonts only cover Latin-1; embed a TTF for other scripts.
- Totals should be computed from integer cents before formatting, never read back from rounded strings.
- Revoke nothing here:
doc.savetriggers the download directly.
How to use it
- Run
npm i jspdf. - Save as
lib/invoicePdf.ts. - Call it from a click handler:
await downloadInvoicePdf({ number: 'INV-1001', client: 'Northwind Ltd', currency: 'USD', items: [{ description: 'Design work', qty: 2, price: 150 }] }). - It must run in the browser, so keep the call inside a client component.
Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.