HTML to PDF in Node.js (2026)

Generate pixel-perfect PDFs from HTML using Playwright, Puppeteer, or a managed API. Covers headers, footers, multi-page layouts, CSS print styles, and Express download endpoints.

Node.jsPlaywrightPuppeteer PDFExpressApril 2026

Approaches at a Glance

ApproachCSS supportJS executionBinary neededBest for
PlaywrightFullYesChromium (~150 MB)Pixel-perfect, SPAs
PuppeteerFullYesChromium (~150 MB)Same as Playwright
PDFKitNoneNoNoneProgrammatic, dynamic
jsPDFPartial (html2canvas)Client-side onlyNoneBrowser-side export
SnapAPIFullYesNoneServerless, production API

Playwright PDF Generation

Playwright's page.pdf() produces PDF from the rendered Chromium output — full CSS, custom fonts, WebGL, everything. This is the gold standard for HTML→PDF fidelity.

npm install playwright
npx playwright install chromium

Basic PDF from URL

import { chromium } from 'playwright';

const browser = await chromium.launch();
const page = await browser.newPage();

await page.goto('https://example.com/invoice', { waitUntil: 'networkidle' });

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

await browser.close();

PDF from HTML string

const html = `



  


  

Invoice #1042

Client: Acme Corp

Date: April 4, 2026

Total: $1,200.00

`; await page.setContent(html, { waitUntil: 'networkidle' }); const pdfBuffer = await page.pdf({ format: 'A4', printBackground: true }); // pdfBuffer is a Buffer — write to file or return from HTTP endpoint

Headers and footers

await page.pdf({
  format: 'A4',
  printBackground: true,
  displayHeaderFooter: true,
  headerTemplate: `
    
ACME CORP — CONFIDENTIAL
`, footerTemplate: `
Generated Page of
`, margin: { top: '40px', bottom: '40px' }, });
Header/footer CSS: The header and footer templates run in an isolated scope — external stylesheets don't apply. Inline all your styles. Use .date, .pageNumber, .totalPages, .title class names which Playwright populates automatically.

Puppeteer PDF Generation

import puppeteer from 'puppeteer';

const browser = await puppeteer.launch({ headless: 'new' });
const page = await browser.newPage();

await page.goto('https://example.com/report', { waitUntil: 'networkidle2' });

const pdf = await page.pdf({
  format: 'A4',
  printBackground: true,
  displayHeaderFooter: true,
  headerTemplate: '
', footerTemplate: '
Page /
', margin: { top: '50px', bottom: '50px', left: '20mm', right: '20mm' }, }); await browser.close(); // pdf is a Buffer

Express PDF download endpoint with browser reuse

import express from 'express';
import puppeteer from 'puppeteer';

const app = express();
let browser;

async function getBrowser() {
  if (!browser || !browser.connected) {
    browser = await puppeteer.launch({
      headless: 'new',
      args: ['--no-sandbox', '--disable-setuid-sandbox'],
    });
  }
  return browser;
}

app.get('/export/pdf', async (req, res) => {
  const { url, filename = 'export.pdf' } = req.query;
  if (!url) return res.status(400).json({ error: 'url required' });

  const b = await getBrowser();
  const page = await b.newPage();
  try {
    await page.goto(url, { waitUntil: 'networkidle2', timeout: 30000 });
    const pdf = await page.pdf({ format: 'A4', printBackground: true });

    res.set({
      'Content-Type': 'application/pdf',
      'Content-Disposition': `attachment; filename="${filename}"`,
      'Content-Length': pdf.length,
    });
    res.send(pdf);
  } finally {
    await page.close();
  }
});

app.listen(3000);

The most important step for good PDF output is controlling page breaks. Chromium respects standard CSS print media queries:

@media print {
  /* Force page break before major sections */
  .section { page-break-before: always; }

  /* Prevent breaking inside elements */
  .invoice-item, .chart, blockquote {
    page-break-inside: avoid;
  }

  /* Avoid orphaned headings */
  h1, h2, h3 {
    page-break-after: avoid;
  }

  /* Hide interactive elements */
  nav, .sidebar, .chat-widget, button, .no-print {
    display: none !important;
  }

  /* Expand full width (no sidebar) */
  .content { width: 100% !important; max-width: none !important; }

  /* Ensure backgrounds print */
  * { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
}

Inject print CSS before generating PDF

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

// Add print overrides without modifying the source HTML
await page.addStyleTag({
  content: `
    @media print {
      nav, .sidebar, .cookie-banner { display: none !important; }
      .content { width: 100% !important; }
      * { -webkit-print-color-adjust: exact !important; }
    }
  `
});

await page.pdf({ format: 'A4', printBackground: true });

SnapAPI: PDF Without Chromium Infrastructure

Self-hosting Chromium for PDF generation means managing memory leaks, zombie processes, and binary compatibility. SnapAPI handles this as a service:

// Generate PDF from URL — 3 lines
const res = await fetch('https://api.snapapi.pics/v1/screenshot', {
  method: 'POST',
  headers: { 'X-Api-Key': process.env.SNAPAPI_KEY, 'Content-Type': 'application/json' },
  body: JSON.stringify({
    url: 'https://example.com/invoice',
    format: 'pdf',
    pdfOptions: {
      format: 'A4',
      printBackground: true,
      displayHeaderFooter: false,
      margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
    },
  }),
});
const pdfBuffer = Buffer.from(await res.arrayBuffer());
// Or via the SDK
import { SnapAPI } from 'snapapi-js';

const snap = new SnapAPI({ apiKey: process.env.SNAPAPI_KEY });

const { data } = await snap.screenshot({
  url: 'https://example.com/report',
  format: 'pdf',
  waitUntil: 'networkidle',
  blockAds: true,
  blockCookieBanners: true,
});
// data is a PDF Buffer

Express PDF endpoint using SnapAPI (no Chromium binary)

import express from 'express';

const app = express();

app.get('/pdf', async (req, res) => {
  const { url } = req.query;
  const snap = await fetch('https://api.snapapi.pics/v1/screenshot', {
    method: 'POST',
    headers: { 'X-Api-Key': process.env.SNAPAPI_KEY, 'Content-Type': 'application/json' },
    body: JSON.stringify({ url, format: 'pdf' }),
  });
  const pdf = Buffer.from(await snap.arrayBuffer());
  res.set({ 'Content-Type': 'application/pdf', 'Content-Disposition': 'attachment; filename="export.pdf"' });
  res.send(pdf);
});

app.listen(3000);
SnapAPI free tier: 200 captures/month, PDF included. No Chromium to install or manage. Get your API key →