ScreenshotNeo

BlogHTML to image & PDF

Create PDFs with Node.js, Jade, and Express

Render Jade/Pug views in Express, print them with Puppeteer, or stream PDFs with PDFKit—with production fixes and a hosted alternative.

By the ScreenshotNeo team1 October 20266 min read

Direct answer: Jade is now called Pug. In Express, render a Pug (or legacy Jade) view to HTML, load that HTML in Puppeteer, call page.pdf(), and send the bytes from the route. Use PDFKit when you want to construct the PDF directly without HTML and CSS.

Express renders templates; it does not convert them to PDF itself. The conversion is a separate browser or PDF-library step. See the Express template-engine guide and Puppeteer’s PDF generation guide.

1. Choose an approach

Approach Best for Trade-offs
Pug/Jade to HTML to Puppeteer Existing HTML, CSS, charts, web fonts, and Express views. Requires a browser process and print-layout checks.
PDFKit Programmatic text, images, and streamed documents. You build the layout with PDF primitives instead of CSS.
ScreenshotNeo A hosted screenshot or PDF endpoint without installing Chromium. Requires an API key and plan usage.

No authoritative source establishes a universal speed or cost winner. Measure representative documents in your own runtime.

2. Configure Express with Pug

mkdir express-pdf && cd express-pdf
npm init -y
npm install express pug puppeteer
mkdir views

Current Express documentation uses Pug. Jade was renamed to Pug; legacy projects should check their installed package and extension. See the Express generator documentation and Pug Express integration.

Create views/invoice.pug:

doctype html
html
  head
    meta(charset='utf-8')
    style.
      @page { size: A4; margin: 18mm; }
      body { font-family: Arial, sans-serif; color: #202124; }
      table { width: 100%; border-collapse: collapse; }
      th, td { border-bottom: 1px solid #ddd; padding: 8px; }
      .total { text-align: right; font-weight: bold; }
  body
    h1= invoice.title
    p Invoice ##{invoice.number} · #{invoice.date}
    p= invoice.customer
    table
      thead
        tr
          th Description
          th Qty
          th Amount
      tbody
        each item in invoice.items
          tr
            td= item.description
            td= item.quantity
            td $#{item.amount.toFixed(2)}
    p.total Total: $#{invoice.total.toFixed(2)}

= escapes interpolated values. Treat request data as untrusted and avoid raw HTML interpolation unless it has been sanitized.

3. Render the view and print it with Puppeteer

const express = require('express');
const puppeteer = require('puppeteer');
const app = express();
app.set('view engine', 'pug');
app.set('views', __dirname + '/views');

app.get('/invoices/:number.pdf', async (req, res, next) => {
  const invoice = {
    title: 'Invoice', number: req.params.number,
    date: new Date().toISOString().slice(0, 10),
    customer: 'Ada Lovelace',
    items: [{ description: 'Consulting', quantity: 2, amount: 125 },
            { description: 'Support', quantity: 1, amount: 50 }]
  };
  invoice.total = invoice.items.reduce((sum, item) => sum + item.quantity * item.amount, 0);
  let browser;
  try {
    const html = await new Promise((resolve, reject) => {
      app.render('invoice', { invoice }, (err, rendered) => err ? reject(err) : resolve(rendered));
    });
    browser = await puppeteer.launch({ headless: true });
    const page = await browser.newPage();
    await page.setContent(html, { waitUntil: 'networkidle0' });
    const pdf = await page.pdf({
      format: 'A4', printBackground: true, preferCSSPageSize: true,
      margin: { top: '18mm', right: '18mm', bottom: '18mm', left: '18mm' }
    });
    res.type('application/pdf')
      .set('Content-Disposition', `attachment; filename='invoice-${invoice.number}.pdf'`)
      .send(pdf);
  } catch (error) { next(error); }
  finally { if (browser) await browser.close(); }
});
app.listen(3000, () => console.log('http://localhost:3000'));
node server.js
curl -OJ http://localhost:3000/invoices/1001.pdf

For dynamic assets, wait for a readiness selector or a bounded delay:

await page.goto(`http://127.0.0.1:3000/preview/${id}`, { waitUntil: 'networkidle0', timeout: 30000 });
await page.waitForSelector('[data-pdf-ready]', { timeout: 10000 });
await page.evaluate(() => document.fonts.ready);
const pdf = await page.pdf({ format: 'A4', printBackground: true });

4. Important Puppeteer options

  • format, or custom width/height, sets paper size.
  • margin sets top, right, bottom, and left margins.
  • printBackground: true preserves background colors and images.
  • preferCSSPageSize: true honors @page.
  • landscape: true rotates the page.
  • pageRanges: '1-3' limits pages.
  • displayHeaderFooter with headerTemplate and footerTemplate adds print headers; use pageNumber and totalPages classes.
  • scale changes print scale; fix CSS overflow before reducing it.
  • Call page.emulateMediaType('screen') when screen media styles are desired; print media is the default.

5. Navigate to an Express route

Navigating to a protected route lets the browser load external CSS, images, and fonts:

const page = await browser.newPage();
await page.setExtraHTTPHeaders({ 'X-PDF-Token': process.env.PDF_TOKEN });
await page.goto(`http://127.0.0.1:3000/secure-invoice/${id}`, {
  waitUntil: 'networkidle0', timeout: 30000
});
const pdf = await page.pdf({ format: 'A4', printBackground: true });

Authorize the document, validate identifiers, restrict outbound navigation when URLs are untrusted, and never expose secrets in rendered markup.

6. Generate PDFs directly with PDFKit

PDFKit’s PDFDocument is a readable Node stream. It does not save automatically; pipe it to the response or a file and call doc.end(). See PDFKit’s getting-started documentation.

const PDFDocument = require('pdfkit');
app.get('/simple.pdf', (req, res) => {
  res.type('application/pdf').set('Content-Disposition', 'inline; filename=simple.pdf');
  const doc = new PDFDocument({ size: 'A4', margin: 50 });
  doc.pipe(res);
  doc.fontSize(20).text('Invoice', { underline: true });
  doc.moveDown().fontSize(12).text('Generated directly with PDFKit.');
  doc.moveDown().text('Total: $300.00');
  doc.end();
});

7. Edge cases and production checklist

  • Wait for images, charts, and fonts; use fixed dimensions to prevent layout shifts.
  • Use print CSS for long tables and page breaks.
  • Validate missing or unauthorized records before rendering.
  • Set navigation, route, and job timeouts.
  • Reuse a controlled browser instance, create pages per job, and cap concurrency according to available memory.
  • Install browser dependencies required by your Puppeteer version in containers.
  • Close pages and browsers in finally blocks.
  • Cache immutable PDFs by document version, never by an unvalidated URL.
  • For large direct-generated documents, stream PDFKit output; for Puppeteer, account for the completed buffer in memory.

8. Troubleshooting

Symptom Cause Fix
Browser fails to launch Missing libraries, executable, or container permissions. Install the dependencies documented for your Puppeteer version and verify the executable path.
Blank PDF Data or assets were not ready. Use goto wait conditions plus waitForSelector; inspect rendered HTML.
Missing backgrounds Print backgrounds disabled. Set printBackground: true.
Wrong size or margins Conflicting CSS and PDF options. Choose one source of truth and use preferCSSPageSize when CSS owns layout.
Missing fonts Font files unavailable or still loading. Bundle fonts, verify URLs, and await document.fonts.ready.
Jade view not found Wrong views directory, engine, or extension. Set app.set('views', ...) , use Pug for new projects, and check the filename.
Headers already sent Response started before an error. Generate fully before sending and keep one error path.

9. Performance, reliability, and cost

Latency and memory depend on browser startup, page complexity, external assets, and concurrency. Keep a warm browser, bound simultaneous pages, set timeouts, and record document size and failure reason. PDFKit can stream while generating; Puppeteer normally returns a completed buffer. No researched source provides a universal benchmark.

Make jobs idempotent, retry only transient browser or network failures, and avoid retrying invalid input. For asynchronous generation, persist job status and the resulting object.

10. Or skip the browser setup

ScreenshotNeo is a hosted website screenshot and PDF API. A GET request returns a PDF, PNG, JPEG, or WebP, so an Express service can avoid installing Chromium. See the ScreenshotNeo API docs.

curl -G 'https://api.screenshotneo.com/v1/shot' -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o invoice.pdf
import requests
r = requests.get('https://api.screenshotneo.com/v1/shot', params={'access_key': 'YOUR_API_KEY', 'url': 'https://stripe.com'}, timeout=90)
r.raise_for_status()
open('invoice.pdf', 'wb').write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
if (!res.ok) throw new Error(`ScreenshotNeo returned ${res.status}`);
require('fs').writeFileSync('invoice.pdf', Buffer.from(await res.arrayBuffer()));

Cookie banners, newsletter popups, and chat widgets are removed before the shot. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; response headers identify the page verdict and billing status. An MCP server lets AI agents such as Claude and Cursor take screenshots. The Free plan includes 1,000 screenshots a month with no card; paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.

11. FAQ

Can I keep the .jade extension?

Yes, when your legacy Jade package supports it. New projects should use Pug and .pug.

Should I call res.render() and then convert the response?

Render to an HTML string with app.render(), or navigate Puppeteer to a protected route. Do not send the HTML response before PDF generation.

How do I add page numbers?

Enable displayHeaderFooter and use Puppeteer’s pageNumber and totalPages classes.

When is PDFKit better?

Use it when the document is naturally a sequence of PDF drawing operations and does not need browser CSS or HTML assets.