# jsPDF alternative for HTML to PDF
jsPDF alternative for HTML to PDF. html2canvas output is blurry, unselectable images. See when jsPDF is fine and how to get real vector PDFs.
jsPDF is a solid library for drawing PDFs in the browser. The trouble starts when you use it to convert an existing HTML page, usually through `doc.html()` or html2canvas.
This page explains why those PDFs look wrong, when jsPDF is still right, and how to render HTML with Chrome instead.
**Quick answer.** Send the HTML from your backend to a hosted Chromium API and you get a vector PDF with selectable text.
```bash
curl -X POST https://html-pdf.com/v1/pdf \
-H "Authorization: Bearer $HTML_PDF_KEY" \
-H "Content-Type: application/json" \
-d '{"html":"
Invoice #042
","format":"A4"}' \
-o invoice.pdf
```
## Why jsPDF HTML output looks wrong
This section shows the usual jsPDF pattern and why its output suffers. The common approach is to screenshot a DOM node with html2canvas, then place that image into a jsPDF document.
```js
import html2canvas from "html2canvas";
import { jsPDF } from "jspdf";
const canvas = await html2canvas(document.querySelector("#invoice"), { scale: 2 });
const pdf = new jsPDF({ format: "a4", unit: "mm" });
pdf.addImage(canvas.toDataURL("image/png"), "PNG", 0, 0, 210, (canvas.height * 210) / canvas.width);
pdf.save("invoice.pdf");
```
That approach has built-in limits.
- **Image-based output.** Every page is a picture. Text can't be selected, searched, or copied, and screen readers can't read it.
- **Blurry text.** Raising `scale` sharpens the image but makes the file much bigger.
- **Bad page splitting.** The canvas is one tall image cut into page-height slices, so lines of text and table rows get sliced in half. CSS like `break-inside: avoid` isn't applied.
- **Big files.** A few pages of PNG at 2x scale adds up fast.
- **CSS is approximated.** html2canvas re-implements rendering, so some CSS and cross-origin images render differently or not at all.
`doc.html()` uses html2canvas under the hood, so it has the same rendering limits.
## When jsPDF is the better choice
jsPDF is a good fit when you **draw** the document instead of converting HTML.
- Simple, code-generated documents (labels, tickets, receipts) built with `doc.text()`, `doc.rect()`, and the autotable plugin.
- Apps that must work **offline** or can't send data to a server.
- Fully client-side tools where the user's data should never leave the browser.
If you control every coordinate, jsPDF produces real text and small files. The problems above come from converting HTML.
## Comparison
This table compares the two jsPDF approaches with Chrome rendering.
| | jsPDF + html2canvas | jsPDF drawing API | Chrome rendering (html-pdf.com) |
|---|---|---|---|
| Runs | Browser | Browser | Server (API) |
| Selectable text | No (image) | Yes | Yes (vector) |
| Uses your existing HTML/CSS | Approximated | No, you redraw it | Yes, real Chromium |
| Flexbox, grid, web fonts | Partial | N/A | Yes |
| Page breaks (`break-inside`, `@page`) | No | Manual | Yes |
| Works offline | Yes | Yes | No |
| Cost | Free | Free | 1 credit per page |
## The alternative: render HTML with Chrome on the server
This section shows how to replace the html2canvas step with real Chrome rendering. Chrome's print engine produces vector PDFs with selectable text, follows `@page` and break rules, and supports modern CSS.
You can run headless Chrome yourself (Puppeteer, Playwright) or call an API that does it for you.
With html-pdf.com, your frontend calls **your own backend route**, and the backend calls the API. Never put the API key in browser code. Anyone could read it from the bundle and spend your credits.
### Backend route (Node + Express)
```js
import express from "express";
const app = express();
app.use(express.json({ limit: "5mb" }));
app.post("/api/pdf", async (req, res) => {
const { html } = req.body; // build or validate this server-side in real apps
const r = await fetch("https://html-pdf.com/v1/pdf", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.HTML_PDF_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ html, format: "A4", margin: "15mm", max_pages: 20 }),
});
if (!r.ok) return res.status(502).send(await r.text());
res.set("Content-Type", "application/pdf");
res.send(Buffer.from(await r.arrayBuffer()));
});
app.listen(3001);
```
### React download button
```jsx
export function DownloadPdf({ targetId }) {
async function download() {
const el = document.getElementById(targetId);
const css = [...document.styleSheets]
.map((s) => { try { return [...s.cssRules].map((r) => r.cssText).join("\n"); } catch { return ""; } })
.join("\n");
const html = `${el.outerHTML}`;
const res = await fetch("/api/pdf", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ html }),
});
if (!res.ok) throw new Error("PDF failed");
const url = URL.createObjectURL(await res.blob());
Object.assign(document.createElement("a"), { href: url, download: "document.pdf" }).click();
URL.revokeObjectURL(url);
}
return ;
}
```
In Vue the same `download()` function works inside a method or `