Generate PDFs in Node.js: Puppeteer, PDFKit, and jsPDF
Use Puppeteer to print an HTML report, PDFKit to draw a document from data, or jsPDF with html2canvas to download an image of a browser element. These open-source examples produce PDFs with different text and layout properties, which matter when you choose an export method.
This is PDF generation, not general document conversion: none of these examples converts Word, spreadsheet, or PDF files into other editable formats.
Choosing the right library
| Your input | Example | Output to expect |
|---|---|---|
| Local HTML with CSS | Puppeteer | An A4 print layout with selectable text |
| Strings and drawing instructions | PDFKit | An A4 document with selectable text; you set the coordinates |
| A browser element | jsPDF + html2canvas | An image fitted onto one A4 page, without selectable text |
Set up the examples
These examples were tested on Linux with Node.js 26.8.1,
Yarn 4.12.0, and Puppeteer’s Chrome for Testing 151.0.7922.71. Use these tested versions to reproduce
the walkthrough; they are not the libraries’ minimum requirements. You need Corepack and unzip
installed before creating the project; follow the
Yarn installation guide if corepack --version fails.
Chrome also needs the system libraries listed in
Puppeteer’s system requirements.
Paste this into Bash from a writable directory. It creates a separate project with local package
and browser caches, and returns to your original directory. An existing node-pdf-demo is left
alone. If installation fails after creation, fix the reported problem, then repeat the two
corepack yarn commands inside that folder; you do not need to recreate it.
(
corepack --version &&
unzip -v >/dev/null &&
mkdir node-pdf-demo &&
cd node-pdf-demo &&
printf '%s\n' '{"private":true,"packageManager":"yarn@4.12.0"}' > package.json &&
touch yarn.lock &&
printf '%s\n' 'nodeLinker: node-modules' 'enableGlobalCache: false' > .yarnrc.yml &&
PUPPETEER_CACHE_DIR="$PWD/.puppeteer" corepack yarn add --exact \
puppeteer@25.5.0 pdfkit@0.17.2 jspdf@4.2.1 html2canvas@1.4.1 &&
PUPPETEER_CACHE_DIR="$PWD/.puppeteer" corepack yarn exec puppeteer browsers install chrome
)
The explicit browser install works even when dependency install scripts are disabled. Puppeteer’s
installation guide explains that distinction. Save the files
below in node-pdf-demo, and run the Node commands from that directory. The .cjs extension keeps
the server examples in CommonJS even inside an ES-module project.
HTML-to-PDF conversion with Puppeteer
Puppeteer uses Chrome’s print renderer. Its page.pdf() method
uses print CSS by default, so a PDF can differ from the screen layout. This example uses only local,
self-contained HTML. Render HTML you control; it is not a service for accepting arbitrary URLs or
untrusted scripts.
Print a local report
Save this as report.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Puppeteer report</title>
<style>
body { margin: 0; font-family: Arial, sans-serif; }
main { padding: 32px; }
h1 { font-size: 28px; }
.marker { height: 24px; background: #c81e50; }
footer { margin-top: 160px; }
</style>
</head>
<body>
<main>
<h1>Hello, Puppeteer!</h1>
<p>This report keeps its text selectable.</p>
<div class="marker"></div>
<footer>End of report.</footer>
</main>
</body>
</html>
Save this as html-to-pdf.cjs:
const { pathToFileURL } = require('node:url')
const puppeteer = require('puppeteer')
async function htmlToPdf(url, outputPath) {
const browser = await puppeteer.launch()
try {
const page = await browser.newPage()
const response = await page.goto(url, { waitUntil: 'networkidle0' })
if (!response || !response.ok()) {
throw new Error(`Page load failed: ${response?.status() ?? 'no HTTP response'}`)
}
await page.pdf({
path: outputPath,
format: 'A4',
printBackground: true,
margin: { top: '10mm', right: '10mm', bottom: '10mm', left: '10mm' },
})
} finally {
await browser.close()
}
}
const inputUrl = pathToFileURL(process.argv[2] ?? 'report.html').href
htmlToPdf(inputUrl, process.argv[3] ?? 'puppeteer.pdf').catch((error) => {
console.error(error.message)
process.exitCode = 1
})
PUPPETEER_CACHE_DIR="$PWD/.puppeteer" node html-to-pdf.cjs report.html puppeteer.pdf
Open puppeteer.pdf. It should contain one page, a colored bar, and
End of report. below the body text. Select and copy
Hello, Puppeteer! to check that it is text rather than a picture.
printBackground: true includes the CSS bar. Longer HTML flows across print pages; add print styles
for your own page breaks. A dynamic page needs its own readiness condition, since network idleness
does not establish that every application update has finished.
Generating PDFs with PDFKit
PDFKit creates content through its drawing and text APIs; it does not interpret your HTML or CSS. Use it when you have data and want to control where that data goes on the page. Its getting-started guide describes the document stream and page options.
Draw a document
Save this as create-pdf.cjs. Waiting for pipeline() reports output-stream errors before the
program completes:
const PDFDocument = require('pdfkit')
const fs = require('node:fs')
const { pipeline } = require('node:stream/promises')
async function createPdf(outputPath) {
const doc = new PDFDocument({ size: 'A4' })
const writing = pipeline(doc, fs.createWriteStream(outputPath))
try {
doc.fontSize(25).text('Hello, PDFKit!', 100, 100)
doc.fontSize(12).text('This is a sample PDF generated using PDFKit.', 100, 150)
doc.text('End of report.', 100, 350)
doc.end()
} catch (error) {
doc.destroy(error)
}
await writing
}
createPdf(process.argv[2] ?? 'pdfkit.pdf').catch((error) => {
console.error(error.message)
process.exitCode = 1
})
node create-pdf.cjs pdfkit.pdf
Open pdfkit.pdf and try copying Hello, PDFKit! from the heading.
The three text lines should fit on one A4 page. Coordinates are in PDF points, with 72 points per
inch. PDFKit can wrap text, but a document with tables or repeating headers still needs layout logic; an HTML template
does not carry over to these APIs.
Both Node commands replace an existing destination when writing begins. A missing input or failed browser launch can leave an earlier PDF untouched, and a failed write can leave a partial file. Use a new filename if you need to preserve an earlier export. To check error handling, pass a path inside a nonexistent directory as the output; the command should report an error and exit nonzero.
Client-side PDF generation with jsPDF
jsPDF can create text-based PDFs too. Here, html2canvas renders one HTML element to a canvas, and jsPDF embeds that canvas as an image. html2canvas reconstructs the element from supported DOM and CSS properties; its documentation explains why it can differ from a browser screenshot.
Download an image of a browser element
Save this complete file as browser-pdf.html, beside node_modules, and open it in Chrome:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Browser PDF export</title>
<style>
#content { box-sizing: border-box; width: 640px; padding: 32px; font: 16px Arial; }
h1 { font-size: 28px; }
.marker { height: 24px; background: #c81e50; }
footer { margin-top: 160px; }
</style>
</head>
<body>
<script src="./node_modules/jspdf/dist/jspdf.umd.min.js"></script>
<script src="./node_modules/html2canvas/dist/html2canvas.min.js"></script>
<main id="content">
<h1>Hello, jsPDF!</h1>
<p>This report is exported as an image.</p>
<div class="marker"></div>
<footer>End of report.</footer>
</main>
<button id="download">Download PDF</button>
<p id="status" role="status"></p>
<script>
async function generatePDF() {
const { jsPDF } = window.jspdf
const content = document.getElementById('content')
const canvas = await html2canvas(content, { scale: 2, backgroundColor: '#ffffff' })
const imgData = canvas.toDataURL('image/png')
const pdf = new jsPDF()
const width = pdf.internal.pageSize.getWidth() - 20
const height = pdf.internal.pageSize.getHeight() - 20
const scale = Math.min(width / canvas.width, height / canvas.height)
pdf.addImage(imgData, 'PNG', 10, 10, canvas.width * scale, canvas.height * scale)
await pdf.save('download.pdf', { returnPromise: true })
}
const button = document.getElementById('download')
button.addEventListener('click', async () => {
if (button.disabled) return
button.disabled = true
const status = document.getElementById('status')
status.textContent = ''
try {
await generatePDF()
status.textContent = 'Download requested.'
} catch {
status.textContent = 'Unable to generate the PDF. Please try again.'
} finally {
button.disabled = false
}
})
</script>
</body>
</html>
Click Download PDF. The button stays disabled while the image is
being generated, then the page shows Download requested.
That message means the save request was issued; check your browser’s downloads for download.pdf.
The jsPDF project documents its browser build.
Open the download and check the heading, colored bar, and footer. They should all be visible within the page margins, but you cannot select the heading as text. This example scales the whole image onto one page rather than paginating it, so long reports become small. Use Puppeteer or a text-based layout for searchable text. The image also lacks the original HTML’s semantic structure for screen readers. External images need a compatible same-origin or CORS setup; this example uses no images or web fonts.
Keep wkhtmltopdf within legacy workflows
wkhtmltopdf converts HTML to PDF using an old Qt WebKit engine. Its repository was archived in January 2023, and it is no longer maintained. Keep existing installations limited to trusted HTML. The project’s security guidance warns against untrusted input. For an existing installation, test your own trusted templates against its exact binary and fonts. The examples above do not validate that renderer. Use a maintained renderer such as Puppeteer for new HTML-to-PDF work.
