DEV3 Technologies home

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.addImage from a data URL.
  • Switch the page size to 'letter' for US documents.
  • Use the jspdf-autotable plugin 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.save triggers the download directly.

How to use it

  1. Run npm i jspdf.
  2. Save as lib/invoicePdf.ts.
  3. Call it from a click handler: await downloadInvoicePdf({ number: 'INV-1001', client: 'Northwind Ltd', currency: 'USD', items: [{ description: 'Design work', qty: 2, price: 150 }] }).
  4. 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.

Related snippets