Building PDF reports with jsPDF: when you need a report designer instead
jsPDF is the shortest path from a browser to a PDF, and for a receipt, a label or a one-page form it is the right answer. This is about the second page, the fifth customer, and the moment the layout quietly becomes application state.
Where jsPDF is excellent
It is worth being clear about this first, because most of the advice on the internet about “outgrowing jsPDF” is written by people selling something and starts from the premise that you should never have used it. That premise is wrong.
jsPDF is the right tool when:
- the document is short and its shape does not depend much on the data;
- you want a small MIT-licensed dependency in the browser, with nothing running on a server;
- you are drawing at coordinates — a receipt, a label, a certificate, a ticket — rather than laying out prose;
- a developer is content to be the person who places things, because things rarely move.
Under those conditions it is hard to beat. Four lines and you have a PDF, in a browser, with no build configuration and no service.
import { jsPDF } from "jspdf";
const doc = new jsPDF();
doc.setFontSize(18);
doc.text("Invoice 10241", 20, 24);
doc.setFontSize(11);
doc.text("Aldermead Joinery Ltd", 20, 34);
doc.save("invoice.pdf"); Nothing that follows is a criticism of that. It is what happens to it over eighteen months.
The seven-step progression
This is the same sequence in almost every codebase that ends up here. It is worth recognising which step you are on, because the cost of changing course goes up sharply at step five and the decision usually gets made at step seven, when it is most expensive.
Draw the thing
Text at coordinates, a logo, a couple of rules. It works, it took an afternoon, and it is genuinely the correct amount of engineering for the problem as stated.
Introduce the cursor
Then a field becomes two lines long and pushes into the one below it. So you stop passing literal Y coordinates
and start keeping a running one. You reach for splitTextToSize to wrap, which hands you back an array
of lines, so now your text function is a loop, and it needs to know the page height, so you add a constant.
// Two hours later, the cursor exists.
let y = 24;
const MARGIN = 20;
const BOTTOM = doc.internal.pageSize.getHeight() - MARGIN;
function line(text: string, size = 11, leading = 6) {
doc.setFontSize(size);
// Wrapping is yours to ask for, and the answer is an array of lines.
for (const part of doc.splitTextToSize(text, 170)) {
if (y + leading > BOTTOM) {
doc.addPage();
y = MARGIN;
}
doc.text(part, MARGIN, y);
y += leading;
}
} This is the step where the code stops being about your invoice and starts being about layout. It is still small and still obviously worth it.
Add a table
Line items arrive. Column widths, alignment, a header row, a total. You write it, it is about a hundred lines, and
then somebody has forty line items and it runs off the bottom of the page — so you install
jspdf-autotable, which is the correct decision and solves it properly, including repeating the header
row on every page.
Note what has just happened, though: your document now has two layout engines in it. The table knows where it is
on the page; your own cursor does not. Keeping them in agreement is what
didDrawPage hooks are for, and it is the first piece of genuinely fiddly code in the file.
Pagination stops being about overflow
The real requirements arrive together. A heading must not be the last thing on a page. A chart must not be split across two. The signature block must stay with the paragraph above it. “Page 3 of 7” has to appear in the footer, and the 7 is not known until everything has been drawn — so either you draw the whole document twice, or you go back and stamp the totals afterwards by looping over pages.
Every one of those rules needs the height of something before it is drawn, and jsPDF does not measure. It gives
you getTextWidth and splitTextToSize, which are enough to work out a paragraph's height
if you know its font, its size and its leading — so you write that. Then a block gets a border and padding, and
you write that too.
// What you actually wanted to write, and cannot.
//
// The height of the chart is known — you chose it. The height of the
// heading is roughly known. The height of the paragraph underneath is
// not, because it depends on wrapping, which depends on the font, which
// depends on whether the customer's brand loaded.
//
// So this function has to measure something that has not been drawn:
function keepTogether(blocks: Block[]) {
const height = blocks.reduce((total, b) => total + measure(b), 0);
// ^^^^^^^
// this is the library you are writing
if (y + height > BOTTOM) { doc.addPage(); y = MARGIN; }
for (const b of blocks) draw(b);
}
This is the step that matters. measure() is not a helper. It is a typesetting engine, and you have
just agreed to maintain one as a side effect of producing an invoice.
Branding
The first customer wants their typeface. jsPDF ships with the fourteen standard PDF faces and anything else goes in through its virtual file system, which is fine — but every height calculation you wrote in step four was measured against Helvetica, and now it is not Helvetica.
// Branding, once there is more than one customer.
doc.addFileToVFS("Aldermead-Regular.ttf", base64Font);
doc.addFont("Aldermead-Regular.ttf", "Aldermead", "normal");
doc.setFont("Aldermead");
// And now every measurement above is wrong, because they were all
// taken against Helvetica.
Then a logo. addImage takes a compression argument, and without one the bitmap goes in uncompressed,
which is the usual reason a generated PDF is six megabytes. Then a chart: jsPDF's core has no SVG, so it is either
svg2pdf.js as a separate plugin, or html2canvas through the .html() helper,
which rasterises — and a rasterised chart is blurry when printed and unsearchable for ever.
The second customer, and the fifth
Now there are variants. The honest version is a conditional; the tidy version is a config object with colours,
a logo and some flags in it; the version everybody actually ends up with is a folder called
templates/ containing near-identical files with customer names on them.
None of it is testable in any satisfying way. The output is a binary, the failure mode is “looks wrong”, and the only real test is a person opening it.
They ask to do it themselves
And this is the end of the road, because there is no version of jsPDF where a customer moves a section. The layout is compiled into your application. Handing it over means either building an editor that emits your drawing calls — which is a report designer, written by you, from scratch — or saying no.
The thing to notice is that nobody made a bad decision anywhere in that sequence. Each step was the smallest reasonable move from the step before. The cost is only visible from the end.
Where the line actually is
It is further along than most people assume, and it is not about document count or complexity. It is about who needs to be able to change the layout, and how often.
Stay with jsPDF if all of these are true:
- Engineering owns every document, and is happy to.
- Layout changes arrive at roughly the rate of releases, not faster.
- The documents are short enough that pagination is a page-full check rather than a set of rules.
- Nobody has ever asked for a document to look different for a particular customer.
Start looking when any two of these are true:
- You have written a
measure()function, or you are about to. - There is a folder of templates and the difference between two of them is not obvious from the filenames.
- “Report changes” is a recurring item in planning.
- A sales conversation has turned on whether a layout can be changed.
- Somebody has asked for the header on a second page, and you had to think about it.
What the alternatives cost
There are three honest directions, and they fail differently.
| Direction | What you gain | What it costs |
|---|---|---|
| Headless Chromium Puppeteer, Playwright | CSS. Real text shaping, flexbox, grid, and a layout engine nobody on your team has to maintain. | A browser to install, patch, keep alive and pay for in memory. And the browser owns the page breaks: break-inside is a request, not a guarantee, and “page 3 of 7” is still awkward. |
| A layout-aware library pdfmake, react-pdf | You describe the document and something else measures it. Tables, page breaks and repeated headers stop being yours. | The layout is still code, so it still changes at the speed of your release process. Their layout model is theirs, not CSS, and you will meet its edges. |
| An embedded report designer | The layout becomes data your users edit. Pagination, branding and output stop being your problem entirely. | A commercial dependency and a document model to learn. Not worth it for one stable document — genuinely. |
Facts about jsPDF here come from its own documentation and repository, and from
jspdf-autotable and svg2pdf.js, checked on the date at the top of this page:
jsPDF,
its API docs,
jspdf-autotable,
svg2pdf.js.
If something has changed, tell us at hello@broadpaper.com.
Where BroadPaper fits
BroadPaper is the third direction. It is an embeddable visual report designer: you declare the data fields that exist, your users lay the document out against them, and the SDK owns measurement, pagination, branding and the PDF. What gets saved is JSON, so a layout becomes a row in your database rather than a file in your repository.
The three things from the progression above that it takes off you completely:
- Measurement and pagination. Sections are measured as one continuous galley and a pure paginator assigns each page a clip window, so keep-together, orphans and widows, repeated table headers and “page 3 of 7” are settings rather than code. Neither the browser nor the PDF engine gets a vote.
- Branding. A template that names theme tokens re-brands with one option. Fonts are declared once and the same TrueType bytes both measure the layout and draw the file, so changing the typeface does not silently invalidate every height in the document.
- The last step. Customers changing layouts is the thing the product is for, rather than the thing it cannot do.
There is no migration path, and that is not a dodge — jsPDF code is a sequence of drawing calls and a BroadPaper report is data, so nothing converts between them. Teams who move usually leave the fixed receipt exactly where it is and reach for the designer for the document that outgrew being placed by hand.
If you want the two side by side rather than the story: jsPDF and BroadPaper, feature by feature.
Questions
- Is jsPDF good for reports?
- For short, fixed documents, yes — it is small, MIT-licensed, runs anywhere and has no server. It becomes expensive when a document runs to several pages with content whose height depends on the data, because jsPDF draws where you tell it and never measures, so every layout decision that depends on knowing a height is code you write and maintain.
- How do I handle page breaks in jsPDF?
- You track the vertical cursor yourself, compare it against
doc.internal.pageSize.getHeight()minus your margin, and calladdPage()before you overflow. For tables,jspdf-autotabledoes it for you and repeats the header. For anything else — a chart that must not be split, a heading that must stay with its paragraph — you are writing the rule, and the rule has to know the height of something you have not drawn yet. - When should I use jsPDF instead of a report designer?
- When your developers own every layout, the layouts are stable, and there are only a few of them. That covers most receipts, labels, confirmations and one-page forms, and in that situation a designer is a dependency you do not need.
- What is the alternative to jsPDF for multi-page documents?
- Either something that lays out and measures for you — a typesetting engine or a report designer — or headless Chromium, where the browser paginates and you accept its answers. The trade-offs differ: Chromium gives you CSS and a browser to install and keep alive; a layout engine gives you explicit typesetting controls and a document model to learn.
- Can I keep jsPDF for some documents?
- Yes, and most teams do. There is no migration to run — jsPDF code is a sequence of drawing calls and nothing converts it to anything else — so the usual pattern is to leave the fixed receipt exactly where it is and reach for something else for the document that outgrew being placed by hand.
See what the other end of the progression looks like.
A designer, a paginator you did not write, and a four-page vector PDF made in your own browser. No account, nothing uploaded.