HTML to PDF in Vue: what each option really outputs

You have a Vue component (an invoice, a report, a receipt) and want a "Download PDF" button. Most Vue tutorials point to vue3-html2pdf or to html2pdf.js directly, so we ran both on the same 40-row invoice component, then rendered it on the server with Vue's own renderToString and printed it with Chromium. Versions: Vue 3.5.44, vue3-html2pdf 1.1.2, html2pdf.js 0.14.0, Vite 8.3, headless Chromium and Node 22, 21 October 2026. We opened every PDF with poppler.

ApproachWhat came out
vue3-html2pdf
wrapper around html2pdf.js 0.9.3
592 KB, 3 pages, page 1 blank, 0 selectable characters, the link removed. Its README's slot="pdf-content" markup gives a blank 3 KB PDF in Vue 3.
html2pdf.js
html2canvas + jsPDF, in the browser
202 KB, 2 pages, rows kept whole and the link kept, but 0 selectable characters: each page is a 719 px wide JPEG.
Vue SSR + Chromium API
renderToString, then /v1/pdf
20 KB, 2 pages, 1,082 selectable characters, the link kept, rows kept whole, "1/2" page numbers, 0.4 s.

The test component

An ordinary component: a CSS grid header, a link and a 40-row table. We wrote it as a plain object with a template string so the same file runs in Vite and in Node with no build step (in the browser, a template string needs Vue's compiler build, vue/dist/vue.esm-bundler.js; in an SFC, the template goes in <template> and nothing else changes).

// Invoice.js: a plain Vue 3 component (an SFC works the same way). Its CSS lives in invoice.css.
export default {
  props: ["number", "customer", "items"],
  computed: {
    total() { return this.items.reduce((s, i) => s + i.qty * i.price, 0); },
  },
  methods: {
    money(n) { return "$" + n.toFixed(2); },
  },
  template: `
    <div class="invoice">
      <header>
        <div><h1>Invoice {{ number }}</h1><p>Billed to {{ customer }}</p></div>
        <div>Acme Ltd<br><a href="https://example.com/pay">Pay online</a></div>
      </header>
      <table>
        <thead><tr><th>Item</th><th class="num">Qty</th><th class="num">Amount</th></tr></thead>
        <tbody>
          <tr v-for="(i, n) in items" :key="n">
            <td>{{ i.name }}</td><td class="num">{{ i.qty }}</td><td class="num">{{ money(i.qty * i.price) }}</td>
          </tr>
          <tr class="total"><td>Total</td><td></td><td class="num">{{ money(total) }}</td></tr>
        </tbody>
      </table>
    </div>`,
};

And its stylesheet, invoice.css, imported by the app (import "./invoice.css") and read by the server route below:

.invoice { font-family: system-ui, sans-serif; color: #111; padding: 32px; }
.invoice header { display: grid; grid-template-columns: 1fr auto; gap: 16px; align-items: start; }
.invoice h1 { margin: 0; font-size: 28px; }
.invoice table { width: 100%; border-collapse: collapse; margin-top: 24px; }
.invoice th, .invoice td { padding: 8px; border-bottom: 1px solid #ddd; text-align: left; }
.invoice td.num, .invoice th.num { text-align: right; }
.invoice tr { break-inside: avoid; }
.invoice .total { font-weight: 700; font-size: 18px; }

1. vue3-html2pdf: a blank first page and no links

This is the component we ran, following the README's props (paginate-elements-by-height is required unless manual-pagination is on, and the README uses 1400):

<script setup>
import { ref } from "vue";
import Vue3Html2pdf from "vue3-html2pdf";
import Invoice from "../Invoice.js";

const props = defineProps(["invoice"]);
const pdf = ref(null);
</script>

<template>
  <button id="w" @click="pdf.generatePdf()">Download PDF</button>
  <Vue3Html2pdf ref="pdf" :paginate-elements-by-height="1400" filename="invoice" pdf-format="a4" :manual-pagination="false">
    <template #pdf-content>
      <Invoice v-bind="invoice" />
    </template>
  </Vue3Html2pdf>
</template>

The PDF was 592,319 bytes and 3 pages, and the first page was empty. The reason is in the wrapper's pagination code: it measures only the direct children of the slot and inserts a page break before any child that would push the running height past the limit. Our invoice is a single 1,686 px element, so it got a break inserted before it, which is an empty first page, and nothing inside the table was measured at all. Wrapping each section in its own top-level element helps, but a long table is still one child.

The other two pages are pictures: three 1588 px wide JPEGs (the default pdf-quality is 2), 0 selectable characters, so nobody can search or copy the invoice number and screen readers get nothing. The "Pay online" link is gone too, because the wrapper's default options set enableLinks: false. Rows weren't sliced, since html2pdf.js honours break-inside: avoid here.

Two more things to know before you pick it:

2. html2pdf.js directly: better, still an image

<script setup>
import html2pdf from "html2pdf.js";

const props = defineProps(["target"]); // the element that holds <Invoice>

function save() {
  html2pdf()
    .set({ margin: 10, filename: "invoice.pdf", pagebreak: { mode: ["css"] } })
    .from(props.target)
    .save();
}
</script>

<template>
  <button id="h" @click="save">Download PDF</button>
</template>

Calling html2pdf.js 0.14 yourself (it brings in a current jsPDF, 4.2.1, which npm audit doesn't flag) gives a 202,065-byte 2-page PDF with no blank page. With pagebreak: { mode: ["css"] }, rows stayed whole, and the link stayed clickable. The text is still an image, though: 0 selectable characters, and each page is a 719 px wide JPEG that looks soft when zoomed and prints fuzzy. You can raise html2canvas.scale, but the file grows with the square of the scale. Both approaches also inherit html2canvas's re-implementation of CSS, so newer CSS can come out differently from the screen (measured examples).

3. Render with Vue on the server, print with Chromium

Keep the component, turn it into HTML with renderToString from vue/server-renderer, and let a real Chromium print it. You get the browser's own layout (grid, flexbox, web fonts, break-inside), real text and links, and the API key stays on your server. This Express route is the exact file we ran:

// server.mjs: npm i express vue, then SHOTMINT_KEY=your_key node server.mjs
import express from "express";
import { readFileSync } from "node:fs";
import { createSSRApp, h } from "vue";
import { renderToString } from "vue/server-renderer";
import Invoice from "./Invoice.js";

// renderToString returns markup only, so the CSS goes in by hand.
const css = readFileSync(new URL("./invoice.css", import.meta.url), "utf8");
const app = express();

app.get("/invoice/:number.pdf", async (req, res) => {
  // Load the real invoice from your database here.
  const data = {
    number: req.params.number,
    customer: "Jane Doe",
    items: Array.from({ length: 40 }, (_, i) => ({ name: `Consulting, week ${i + 1}`, qty: 1 + (i % 3), price: 120 })),
  };
  const body = await renderToString(createSSRApp({ render: () => h(Invoice, data) }));
  const html = `<!doctype html><meta charset="utf-8"><style>${css}</style>${body}`;

  const r = await fetch("https://shotmint.creasework.com/v1/pdf", {
    method: "POST",
    headers: { "X-API-Key": process.env.SHOTMINT_KEY, "Content-Type": "application/json" },
    body: JSON.stringify({ html, format: "A4", margin: "12mm", page_numbers: true }),
  });
  if (!r.ok) return res.status(502).send(await r.text());

  res.set("Content-Type", "application/pdf");
  res.set("Content-Disposition", `attachment; filename="invoice-${req.params.number}.pdf"`);
  res.send(Buffer.from(await r.arrayBuffer()));
});

app.listen(3001, () => console.log("http://localhost:3001/invoice/INV-2048.pdf"));

renderToString returns markup only, so the CSS goes into the HTML string by hand, as above. That applies to SFCs too: <style> blocks are pulled out into CSS files by the bundler and aren't part of the rendered string. Inline the stylesheet; a relative <link href="/app.css"> won't resolve, because the HTML is rendered on its own. (Our package.json has "type": "module", since both files use import.) In the Vue app, the button is just a link to the route:

<!-- The browser only asks your server for the file; the API key never reaches the client. -->
<script setup>
defineProps(["number"]);
</script>

<template>
  <a :href="`/invoice/${number}.pdf`" download>Download PDF</a>
</template>

Result on the same invoice: a 20,533-byte A4 PDF with 2 pages, 1,082 characters of selectable text, the "Pay online" link clickable, the page break falling between weeks 23 and 24 (no sliced row), and "1/2" and "2/2" at the bottom of each page from page_numbers. The request took 0.4 s end to end.

Using Nuxt? The same fetch to /v1/pdf works in any server route that can return a binary response; we tested the Express version above, not a Nuxt one.

Which one to use

What it costs

One PDF = one credit, however many pages it has. €5 buys 2,000 renders, credits never expire and there's no subscription. Get a free key to try the route above.

Try it with 25 free renders a month

No signup, no card. Get a key in one click, then buy credits only if you need more (from €5 for 2,000, never expire). From a script, an AI agent, Make or Zapier, POST /v1/keys/free (no body) returns a key as JSON (openapi.json, no-code recipes).

Get a free API key Full API reference

More guides: HTML to PDF and HTML to image

All guides: guides · integrations · comparisons