DEV Community

Vitalii Holben
Vitalii Holben

Posted on

Generating PDFs From Web Pages Without a PDF Library

Most PDF generation tutorials start with "install this PDF library" and then you spend an hour fighting with fonts, CSS support, and layout quirks. There's a simpler path if your content already lives on a web page.

The idea

Instead of rendering HTML to PDF inside your app, render the page in a real browser and print it to PDF. Chrome's DevTools Protocol has Page.printToPDF — same engine that powers "Save as PDF" in your browser, but automated.

Quick setup with Puppeteer

const puppeteer = require('puppeteer');

async function pageToPdf(url, outputPath) {
  const browser = await puppeteer.launch({ headless: 'new' });
  const page = await browser.newPage();

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

  await page.pdf({
    path: outputPath,
    format: 'A4',
    margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
    printBackground: true
  });

  await browser.close();
}

pageToPdf('https://example.com/invoice/123', 'invoice.pdf');
Enter fullscreen mode Exit fullscreen mode

That's it. Full CSS support, web fonts, flexbox, grid — everything renders exactly as it does in Chrome.

Things that tripped me up

Print stylesheets matter. If the page has @media print rules, they'll apply. This is actually useful — hide navigation, sidebars, cookie banners. But if you're not expecting it, your PDF might look nothing like the page.

@media print {
  nav, footer, .cookie-banner { display: none; }
  .content { width: 100%; margin: 0; }
}
Enter fullscreen mode Exit fullscreen mode

waitUntil: 'networkidle0' isn't always enough. Pages with lazy-loaded images or JS-rendered charts might need explicit waits:

await page.waitForSelector('.chart-container canvas', { timeout: 5000 })
  .catch(() => console.log('chart not found, proceeding anyway'));
Enter fullscreen mode Exit fullscreen mode

I learned this the hard way — generated 200 invoices with blank chart sections before noticing.

Headers and footers. The headerTemplate and footerTemplate options exist but they're fiddly. They use a tiny isolated context with limited CSS. For anything complex, I just bake the header into the page HTML itself.

When this approach makes sense

  • Invoices, reports, receipts — anything you already display on a web page
  • Generating PDFs from user-created content (blog posts, documentation)
  • Any situation where you want the PDF to match the web version pixel-for-pixel

Where it doesn't: high-volume generation (thousands per minute) where the browser overhead adds up, or cases where you need PDF-specific features like form fields or digital signatures.

Running it without managing Chrome

If you don't want to deal with keeping a Chrome instance running on your server — especially in containerized environments where Chromium dependencies are a pain — screenshot APIs like ScreenshotRun handle the browser part. You send a URL, get back a PDF or PNG. Offloads the rendering infrastructure entirely.

One more thing

The page.pdf() method has a scale option. Default is 1. Setting it to 0.8 or 0.9 can help fit content that's slightly too wide for the page. Beats reworking your CSS for print.

await page.pdf({
  path: 'output.pdf',
  format: 'A4',
  scale: 0.85,
  printBackground: true
});
Enter fullscreen mode Exit fullscreen mode

Not something I see mentioned often but it's saved me from the "content gets cut off on the right edge" problem more than once.

Top comments (0)