Skip to main content

Best npm Packages for PDF Generation 2026

·PkgPulse Team
0

TL;DR

For programmatic PDFs (invoices, reports): PDFKit or @react-pdf/renderer. For HTML-to-PDF (screenshots of web pages): Puppeteer or Playwright. For dynamic PDFs from templates: pdfmake or jsPDF. Avoid Puppeteer for high-volume PDF generation — headless Chrome is expensive. Use PDFKit for server-side performance.

Key Takeaways

  • PDFKit: Low-level PDF generation, Node.js native, ~500KB, no browser needed
  • @react-pdf/renderer: React components → PDF, great for invoice/report templates
  • Puppeteer/Playwright: HTML → PDF (screenshot-based), best for pixel-perfect web pages
  • pdfmake: JSON document definition → PDF, good for tabular reports
  • 2026 winner by use case: PDFKit (server perf) | @react-pdf (dev experience) | Playwright (web-to-PDF)

Downloads

PackageWeekly DownloadsTrend
pdfkit~1.2M→ Stable
@react-pdf/renderer~800K↑ Growing
jspdf~2.5M→ Stable
pdfmake~500K→ Stable

PDFKit: Low-Level Server PDF

npm install pdfkit
// Programmatic PDF generation with PDFKit:
import PDFDocument from 'pdfkit';
import { createWriteStream } from 'fs';

// Invoice PDF:
export async function generateInvoice(invoice: Invoice): Promise<Buffer> {
  return new Promise((resolve, reject) => {
    const doc = new PDFDocument({ margin: 50 });
    const chunks: Buffer[] = [];

    doc.on('data', (chunk) => chunks.push(chunk));
    doc.on('end', () => resolve(Buffer.concat(chunks)));
    doc.on('error', reject);

    // Header:
    doc
      .fontSize(24)
      .font('Helvetica-Bold')
      .text('INVOICE', 50, 50)
      .fontSize(10)
      .font('Helvetica')
      .text(`Invoice #${invoice.number}`, { align: 'right' })
      .text(new Date(invoice.date).toLocaleDateString(), { align: 'right' });

    // Company info:
    doc
      .moveDown(2)
      .fontSize(12)
      .text(invoice.companyName, 50)
      .fontSize(10)
      .text(invoice.companyAddress)
      .text(invoice.companyEmail);

    // Bill to:
    doc
      .moveDown()
      .fontSize(12)
      .text('Bill To:', 50)
      .fontSize(10)
      .text(invoice.clientName)
      .text(invoice.clientEmail);

    // Line items table:
    doc.moveDown(2);
    const tableTop = doc.y;
    
    // Table headers:
    doc
      .fontSize(10)
      .font('Helvetica-Bold')
      .text('Description', 50, tableTop)
      .text('Qty', 300, tableTop)
      .text('Price', 370, tableTop)
      .text('Total', 440, tableTop);

    // Horizontal line:
    doc
      .moveTo(50, tableTop + 15)
      .lineTo(550, tableTop + 15)
      .stroke();

    // Line items:
    doc.font('Helvetica');
    let y = tableTop + 25;
    for (const item of invoice.items) {
      doc
        .text(item.description, 50, y)
        .text(item.quantity.toString(), 300, y)
        .text(`$${item.price.toFixed(2)}`, 370, y)
        .text(`$${(item.quantity * item.price).toFixed(2)}`, 440, y);
      y += 20;
    }

    // Total:
    doc
      .moveTo(50, y + 10)
      .lineTo(550, y + 10)
      .stroke()
      .font('Helvetica-Bold')
      .text('Total:', 370, y + 20)
      .text(`$${invoice.total.toFixed(2)}`, 440, y + 20);

    doc.end();
  });
}
// Serve PDF in Next.js API route:
// app/api/invoice/[id]/route.ts
export async function GET(req: Request, { params }: { params: { id: string } }) {
  const invoice = await db.invoice.findUnique({
    where: { id: params.id },
    include: { items: true, client: true },
  });

  if (!invoice) return new Response('Not found', { status: 404 });

  const pdf = await generateInvoice(invoice);

  return new Response(pdf, {
    headers: {
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="invoice-${invoice.number}.pdf"`,
      'Content-Length': pdf.length.toString(),
    },
  });
}

@react-pdf/renderer: React Components → PDF

npm install @react-pdf/renderer
// React-based PDF document — great for invoices, reports:
import {
  Document, Page, Text, View, StyleSheet, PDFDownloadLink,
  Font, Image,
} from '@react-pdf/renderer';

// Register custom font:
Font.register({
  family: 'Inter',
  fonts: [
    { src: '/fonts/Inter-Regular.ttf' },
    { src: '/fonts/Inter-Bold.ttf', fontWeight: 'bold' },
  ],
});

const styles = StyleSheet.create({
  page: { padding: 40, fontFamily: 'Inter', fontSize: 10 },
  header: { flexDirection: 'row', justifyContent: 'space-between', marginBottom: 30 },
  title: { fontSize: 24, fontWeight: 'bold', color: '#1a1a1a' },
  table: { display: 'flex', width: 'auto', marginTop: 20 },
  tableRow: { flexDirection: 'row', borderBottomWidth: 1, borderBottomColor: '#e5e7eb', paddingVertical: 8 },
  tableHeader: { backgroundColor: '#f9fafb', fontWeight: 'bold' },
  col1: { width: '50%' },
  col2: { width: '15%', textAlign: 'center' },
  col3: { width: '17.5%', textAlign: 'right' },
  col4: { width: '17.5%', textAlign: 'right' },
  totalRow: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 16 },
  totalLabel: { fontWeight: 'bold', marginRight: 8 },
});

function InvoicePDF({ invoice }: { invoice: Invoice }) {
  return (
    <Document>
      <Page size="A4" style={styles.page}>
        {/* Header */}
        <View style={styles.header}>
          <View>
            <Text style={styles.title}>INVOICE</Text>
            <Text style={{ color: '#6b7280', marginTop: 4 }}>#{invoice.number}</Text>
          </View>
          <View style={{ textAlign: 'right' }}>
            <Text style={{ fontWeight: 'bold' }}>{invoice.companyName}</Text>
            <Text style={{ color: '#6b7280' }}>{invoice.companyEmail}</Text>
            <Text style={{ color: '#6b7280' }}>{new Date(invoice.date).toLocaleDateString()}</Text>
          </View>
        </View>

        {/* Bill To */}
        <View style={{ marginBottom: 20 }}>
          <Text style={{ fontWeight: 'bold', marginBottom: 4 }}>Bill To:</Text>
          <Text>{invoice.clientName}</Text>
          <Text style={{ color: '#6b7280' }}>{invoice.clientEmail}</Text>
        </View>

        {/* Line Items Table */}
        <View style={styles.table}>
          {/* Table Header */}
          <View style={[styles.tableRow, styles.tableHeader]}>
            <Text style={styles.col1}>Description</Text>
            <Text style={styles.col2}>Qty</Text>
            <Text style={styles.col3}>Price</Text>
            <Text style={styles.col4}>Total</Text>
          </View>

          {/* Items */}
          {invoice.items.map((item, i) => (
            <View key={i} style={styles.tableRow}>
              <Text style={styles.col1}>{item.description}</Text>
              <Text style={styles.col2}>{item.quantity}</Text>
              <Text style={styles.col3}>${item.price.toFixed(2)}</Text>
              <Text style={styles.col4}>${(item.quantity * item.price).toFixed(2)}</Text>
            </View>
          ))}
        </View>

        {/* Total */}
        <View style={styles.totalRow}>
          <Text style={styles.totalLabel}>Total:</Text>
          <Text style={{ fontWeight: 'bold', fontSize: 14 }}>${invoice.total.toFixed(2)}</Text>
        </View>
      </Page>
    </Document>
  );
}

// Download button in React:
export function DownloadInvoiceButton({ invoice }: { invoice: Invoice }) {
  return (
    <PDFDownloadLink
      document={<InvoicePDF invoice={invoice} />}
      fileName={`invoice-${invoice.number}.pdf`}
    >
      {({ loading }) => (loading ? 'Generating...' : 'Download PDF')}
    </PDFDownloadLink>
  );
}
// Server-side generation with @react-pdf/renderer:
import { renderToBuffer } from '@react-pdf/renderer';

export async function generateInvoicePDF(invoice: Invoice): Promise<Buffer> {
  return renderToBuffer(<InvoicePDF invoice={invoice} />);
}

Playwright/Puppeteer: HTML → PDF

// Playwright HTML-to-PDF (best for web page screenshots):
import { chromium } from 'playwright';

export async function htmlToPDF(html: string): Promise<Buffer> {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  // Set HTML content directly (no server needed):
  await page.setContent(html, { waitUntil: 'networkidle' });

  const pdf = await page.pdf({
    format: 'A4',
    printBackground: true,   // Include background colors/images
    margin: { top: '20mm', right: '15mm', bottom: '20mm', left: '15mm' },
  });

  await browser.close();
  return pdf;
}

// Or navigate to a URL:
export async function urlToPDF(url: string): Promise<Buffer> {
  const browser = await chromium.launch();
  const page = await browser.newPage();

  await page.goto(url, { waitUntil: 'networkidle' });

  // Wait for any lazy content:
  await page.waitForTimeout(500);

  const pdf = await page.pdf({
    format: 'A4',
    printBackground: true,
    displayHeaderFooter: true,
    headerTemplate: '<div style="font-size:10px;text-align:right;width:100%;padding-right:10mm"><span class="date"></span></div>',
    footerTemplate: '<div style="font-size:10px;text-align:center;width:100%"><span class="pageNumber"></span> / <span class="totalPages"></span></div>',
  });

  await browser.close();
  return pdf;
}
// Reuse browser for performance (don't launch per request):
import { Browser, chromium } from 'playwright';

let browser: Browser | null = null;

async function getBrowser() {
  if (!browser || !browser.isConnected()) {
    browser = await chromium.launch();
  }
  return browser;
}

export async function htmlToPDFOptimized(html: string): Promise<Buffer> {
  const browser = await getBrowser();
  const context = await browser.newContext();
  const page = await context.newPage();
  
  await page.setContent(html, { waitUntil: 'networkidle' });
  const pdf = await page.pdf({ format: 'A4', printBackground: true });
  
  await context.close();  // Close context, not browser
  return pdf;
}

pdfmake: JSON Document Definition

npm install pdfmake
npm install -D @types/pdfmake
// pdfmake — define document as JSON:
import PdfPrinter from 'pdfmake';
import { TDocumentDefinitions } from 'pdfmake/interfaces';

// Built-in fonts:
const fonts = {
  Roboto: {
    normal: 'node_modules/pdfmake/build/vfs_fonts.js',
    bold: 'node_modules/pdfmake/build/vfs_fonts.js',
  },
};

const printer = new PdfPrinter(fonts);

export function generateReport(data: ReportData): Buffer {
  const docDefinition: TDocumentDefinitions = {
    content: [
      { text: 'Monthly Report', style: 'header' },
      { text: `Period: ${data.period}`, style: 'subheader' },

      // Summary table:
      {
        table: {
          headerRows: 1,
          widths: ['*', 'auto', 'auto', 'auto'],
          body: [
            // Header row:
            [
              { text: 'Metric', style: 'tableHeader' },
              { text: 'Current', style: 'tableHeader' },
              { text: 'Previous', style: 'tableHeader' },
              { text: 'Change', style: 'tableHeader' },
            ],
            // Data rows:
            ...data.metrics.map(m => [
              m.name,
              m.current.toString(),
              m.previous.toString(),
              { text: `${m.changePercent}%`, color: m.changePercent >= 0 ? 'green' : 'red' },
            ]),
          ],
        },
        layout: 'lightHorizontalLines',
        margin: [0, 10, 0, 20],
      },

      // Chart placeholder:
      { text: 'Key Insights', style: 'subheader' },
      {
        ul: data.insights.map(insight => ({ text: insight })),
      },
    ],
    
    styles: {
      header: { fontSize: 20, bold: true, margin: [0, 0, 0, 10] },
      subheader: { fontSize: 14, bold: true, margin: [0, 15, 0, 5] },
      tableHeader: { bold: true, fillColor: '#f3f4f6' },
    },

    defaultStyle: { font: 'Roboto', fontSize: 10 },
  };

  const doc = printer.createPdfKitDocument(docDefinition);
  const chunks: Buffer[] = [];

  return new Promise((resolve) => {
    doc.on('data', chunk => chunks.push(chunk));
    doc.on('end', () => resolve(Buffer.concat(chunks)));
    doc.end();
  }) as unknown as Buffer;
}

Performance Benchmark

PDF Generation (100 invoices):

PDFKit (pure Node.js):             ~0.8s total   (~8ms/PDF)
@react-pdf/renderer (server):      ~3.2s total   (~32ms/PDF)
pdfmake:                            ~1.4s total   (~14ms/PDF)
Playwright (browser-based):        ~18s total    (~180ms/PDF) *
jsPDF (client-side):               Client only — N/A server

* Browser startup: ~800ms (amortized with persistent browser instance)
* Per-page with persistent browser: ~25ms/PDF

Memory usage:
  PDFKit:     ~20MB RSS
  @react-pdf: ~45MB RSS (JSX runtime overhead)
  Playwright: ~150MB RSS (Chromium process)

Decision Guide

Use PDFKit if:
  → Maximum server performance (8ms/PDF)
  → Programmatic layout (no templates)
  → Memory-constrained environments
  → Simple documents (invoices, receipts, reports)

Use @react-pdf/renderer if:
  → React component model (JSX templates)
  → Design-heavy PDFs (custom fonts, layouts)
  → Team already knows React — DX wins
  → Client-side PDF download in browser

Use Playwright/Puppeteer HTML→PDF if:
  → Need pixel-perfect render of existing web pages
  → Print-to-PDF of dashboards or reports
  → Can't build PDF template from scratch
  → CSS styling is a must (gradients, complex layouts)

Use pdfmake if:
  → JSON-driven templates (data-heavy reports)
  → Dynamic tables and complex grids
  → Need VFS fonts (self-contained)

Use jsPDF if:
  → Client-side only (browser PDF generation)
  → Simple documents, no server involvement
  → Need canvas-based image exports

Avoid for high-volume PDF:
  → Puppeteer launched per request (too slow)
  → @react-pdf server rendering for >1000/min
    (use PDFKit or a PDF queue instead)

Choosing the Right PDF Architecture for Production

PDF generation requirements vary significantly by use case, and choosing the wrong approach creates either performance problems or excessive engineering complexity. The single most important question is whether your PDF must faithfully render an existing web design (dashboard, report page, marketing material) or whether the PDF structure can be defined programmatically from data. These are fundamentally different problems with different optimal solutions.

For web-design-faithful PDFs, Playwright/Puppeteer is the correct tool despite the performance overhead. When your design team has built a beautiful HTML/CSS report page and stakeholders expect the PDF to look identical, implementing that design twice — once in HTML/CSS and again in PDFKit's coordinate-based drawing API — is prohibitively expensive and produces inferior results. The one-second-per-PDF overhead of the Playwright approach is acceptable if generation frequency is low (a few hundred PDFs per day), and the persistent browser instance pattern brings per-PDF time down to 25-30ms when amortized.

For data-driven PDFs (invoices, receipts, statements, certificates), programmatic generation with PDFKit or @react-pdf/renderer is the correct approach. These documents have a stable structure defined by business logic, not design iteration. The 8ms-per-PDF performance of PDFKit makes it viable for high-volume generation — thousands of invoices per hour — without special infrastructure.

Font Handling and Typography in PDFs

Typography is one of the areas where PDF generation surprises developers. Unlike browsers, which can load fonts from CDNs and fall back gracefully, PDF generators need fonts embedded in the PDF file itself to guarantee consistent rendering across viewers. The font embedding behavior differs significantly between libraries.

PDFKit loads fonts from local file paths and embeds them in the PDF. The built-in fonts (Helvetica, Times-Roman, Courier, and Symbol families) are PDF standard fonts that don't need embedding — every PDF viewer includes them. For custom fonts (Inter, DM Sans, your brand font), you load the TTF or OTF file directly: doc.font('./fonts/Inter-Regular.ttf'). PDFKit subsets the font — it only embeds the glyphs actually used in the document, keeping file sizes manageable.

@react-pdf/renderer uses the same approach through its Font.register() API. The fonts must be accessible at generation time — either local files or URLs. For server-side generation, local files are more reliable than URLs (no network dependency). For fonts served from Google Fonts or a CDN, pre-downloading font files to the server during deployment is the production-safe approach.

Playwright-based PDF generation inherits Chrome's font rendering and can load web fonts from CDNs using page.setContent() with HTML that includes font stylesheet links. The page's fonts are rendered by Chrome's layout engine, which means you get accurate rendering of complex typography including ligatures, kerning, and international scripts. This is another reason Playwright is preferred for design-faithful PDFs — typography is handled by the same engine as the browser.

For PDFs intended for long-term archival, legal compliance, or regulatory submission, the PDF/A standard (ISO 19005) imposes additional requirements. PDF/A documents must embed all fonts, cannot use encryption, must not include JavaScript, and must include embedded color profiles. These requirements ensure that the document can be rendered accurately far in the future, independent of the generating application.

None of the JavaScript PDF libraries in this comparison natively produce PDF/A-compliant output without additional processing. PDFKit can produce a document that is close to PDF/A-1b compliance if you embed all fonts and avoid transparency, but it does not generate the required XMP metadata that formal PDF/A validation requires. The standard approach for generating validated PDF/A from a Node.js service is to generate a standard PDF with PDFKit and then pass it through a conversion tool: Ghostscript (gs -dPDFA=1 -dBATCH -dNOPAUSE -sDEVICE=pdfwrite -sOutputFile=output.pdf input.pdf) or a cloud service like Adobe's PDF services API.

For legal document workflows, the compliance requirement often dictates the toolchain. If your organization has a contract with Adobe or a similar provider for PDF/A generation, use their API as the final step in the pipeline, with PDFKit or @react-pdf/renderer generating the initial document structure. This hybrid approach leverages JavaScript libraries for their developer experience while ensuring compliance through a certified conversion step.

Streaming and Large Document Handling

For PDFs with hundreds of pages — annual reports, complete catalogs, bulk export of user data — memory management during generation becomes important. Building a 500-page document entirely in memory before writing it to disk or responding to an HTTP request consumes significant RAM and delays time-to-first-byte.

PDFKit's stream-based API handles this naturally. The PDFDocument object is a Node.js Readable stream — as each page is generated, the corresponding PDF bytes can be piped to a write stream, an HTTP response, or an S3 upload without holding the entire document in memory. For a 500-page document, this streaming approach means the first page's bytes are transmitted while the last page is still being generated.

@react-pdf/renderer's renderToStream() function enables similar streaming: it returns a Node.js readable stream that produces PDF bytes as the React tree is traversed. For Next.js API routes using streaming responses, renderToStream() provides better time-to-first-byte than renderToBuffer(), which buffers the entire PDF before returning.

Playwright's PDF generation is inherently non-streaming — it renders the entire page before returning the PDF buffer. For multi-page Playwright PDFs generated from long HTML content, the entire page must fit in Chrome's rendering context before the PDF is produced. For very large pages (100+ pages of content), splitting the HTML across multiple page contexts and concatenating the resulting PDFs with a library like pdf-lib may be necessary.

Watermarking, Stamping, and PDF Manipulation

Post-generation PDF manipulation — adding watermarks, stamping page numbers, merging documents, adding digital signatures — requires a different class of tool than initial generation. The pdf-lib package is the leading JavaScript library for reading and modifying existing PDFs. It can add text, images, and drawings to existing pages, merge multiple PDFs, rotate pages, and embed fonts.

A common production pattern combines initial generation with pdf-lib post-processing. PDFKit generates the core invoice content, and pdf-lib stamps a "PAID" watermark, adds the company logo to every page, and merges the invoice with a terms-and-conditions document into a single PDF. This separation of concerns — using PDFKit for content generation and pdf-lib for document assembly — produces a clean architecture that is easier to maintain than trying to handle document assembly within the generation step.

For watermarking specifically, pdf-lib's approach is to add a transparent text element on top of existing page content at a specified opacity, rotation, and position. The resulting watermark is rendered by the PDF viewer as a visual overlay but remains as a separate PDF content stream that could theoretically be removed. For security-critical watermarking (preventing unauthorized distribution of copyrighted content), embedding the watermark using flattening techniques — merging the watermark into the page content stream irreversibly — requires more advanced PDF manipulation than pdf-lib currently provides, and typically requires a commercial PDF library or service.

Compare PDF generation packages on PkgPulse.

See also: Playwright vs Puppeteer and React vs Vue, Best npm Packages for Web Scraping in 2026.

The 2026 JavaScript Stack Cheatsheet

One PDF: the best package for every category (ORMs, bundlers, auth, testing, state management). Used by 500+ devs. Free, updated monthly.