Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Some links on this page are affiliate links: if you buy through them we may earn a commission, at no extra cost to you.

Direct answer: Express and Jade/Pug render an HTML document; they do not create a PDF by themselves. Render the view, load that HTML in a PDF-capable renderer such as Puppeteer, and send the resulting bytes from your Express route. If you need a drawing API rather than browser layout, PDFKit can generate a PDF directly and stream it to the response. Jade is the former name of Pug, so new projects should use Pug terminology and verify package versions in legacy applications.

How the pieces fit together

Express’s res.render() method runs a template engine and produces HTML. The conversion to PDF is a separate operation. In a browser-oriented workflow, Puppeteer opens the rendered HTML and calls page.pdf(). Puppeteer documents that PDF printing uses print CSS media by default; you can explicitly emulate screen media when your design depends on screen styles. See the Express template-engine guide and Puppeteer PDF guide.

The pipeline is therefore:

  1. Configure Express with Pug (the maintained successor to Jade).
  2. Keep an indented template in the views directory.
  3. Render it with trusted application data.
  4. Pass the resulting HTML to Chromium through Puppeteer.
  5. Set PDF options and return the buffer with PDF headers.

Jade or Pug? Resolve the naming first

Jade was renamed to Pug. Current Express examples use app.set('view engine', 'pug'), and the Express generator lists Jade among historical choices while identifying Pug as the default. Pug’s own Express integration documentation shows the modern setup. A legacy application may still contain .jade files and an older package; do not change package names or syntax blindly. Check the installed dependency and its compatibility with your Node.js and Express versions, then migrate deliberately.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Build a working Express-to-PDF route

1. Install the dependencies

In a new application, install Express, Pug, and Puppeteer:

npm install express pug puppeteer

Puppeteer downloads a compatible browser during installation in its standard configuration. In a restricted deployment, you may instead provide a system Chromium executable and configure executablePath; the path and sandbox requirements are environment-specific.

2. Configure Express and create the view

Create app.js and a views directory. Express’s template engine setting tells res.render() which compiler to use.

const express = require('express');
const path = require('node:path');
const puppeteer = require('puppeteer');

const app = express();
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');
app.use(express.json());

app.get('/invoice/:id.pdf', async (req, res, next) => {
  try {
    // Replace this with a database lookup and authorization check.
    const invoice = {
      id: req.params.id,
      customer: 'Ada Lovelace',
      date: '2026-09-29',
      lines: [
        { description: 'Consulting', quantity: 2, unitPrice: 125 },
        { description: 'Support', quantity: 1, unitPrice: 50 }
      ]
    };

    const html = await new Promise((resolve, reject) => {
      res.render('invoice', { invoice }, (err, rendered) => {
        if (err) reject(err); else resolve(rendered);
      });
    });

    const browser = await puppeteer.launch({ headless: true });
    try {
      const page = await browser.newPage();
      await page.setContent(html, { waitUntil: 'networkidle0' });
      const pdf = await page.pdf({
        format: 'A4',
        printBackground: true,
        margin: { top: '18mm', right: '14mm', bottom: '18mm', left: '14mm' },
        preferCSSPageSize: true
      });
      res.set({
        'Content-Type': 'application/pdf',
        'Content-Disposition': `inline; filename="invoice-${invoice.id}.pdf"`,
        'Content-Length': pdf.length
      });
      res.send(pdf);
    } finally {
      await browser.close();
    }
  } catch (error) {
    next(error);
  }
});

app.use((err, req, res, next) => {
  console.error(err);
  if (!res.headersSent) res.status(500).send('Could not create PDF');
});

app.listen(3000, () => console.log('Listening on http://localhost:3000'));

The callback form of res.render() gives you the HTML string without ending the HTTP response, which is convenient when another step must consume it. The route above launches a browser for clarity. For production traffic, manage browser lifetime deliberately (for example, a bounded pool) rather than creating unlimited Chromium processes.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

3. Write the Jade-style template

Save this as views/invoice.pug. The same indentation-oriented syntax is what older Jade templates used.

doctype html
html
  head
    meta(charset='utf-8')
    title Invoice #{invoice.id}
    style.
      @page { size: A4; margin: 18mm 14mm; }
      * { box-sizing: border-box; }
      body { font-family: Arial, sans-serif; color: #202124; font-size: 12pt; }
      h1 { margin: 0 0 8mm; }
      .meta { margin-bottom: 10mm; color: #555; }
      table { width: 100%; border-collapse: collapse; }
      th, td { padding: 3mm; border-bottom: 1px solid #ddd; text-align: left; }
      th:last-child, td:last-child { text-align: right; }
      .total { margin-top: 8mm; text-align: right; font-weight: bold; }
      tr { break-inside: avoid; }
  body
    h1 Invoice #{invoice.id}
    .meta
      div Customer: #{invoice.customer}
      div Date: #{invoice.date}
    table
      thead
        tr
          th Description
          th Quantity
          th Unit price
          th Amount
      tbody
        - let total = 0
        each line in invoice.lines
          - const amount = line.quantity * line.unitPrice
          - total += amount
          tr
            td= line.description
            td= line.quantity
            td $#{line.unitPrice.toFixed(2)}
            td $#{amount.toFixed(2)}
    .total Total: $#{total.toFixed(2)}

Use escaped interpolation (#{...} or =) for values that may contain user input. Raw HTML insertion is powerful but can turn a stored or request-supplied string into markup or script; only use it for content you have sanitized and intentionally approved.

Control print layout with CSS and Puppeteer

Print versus screen media

page.pdf() renders with print media by default. Put PDF-specific rules in @media print, or call await page.emulateMediaType('screen') before generating the PDF when you need screen styles. Use @page for paper size and margins, and set printBackground: true when colored backgrounds or images are part of the design.

Page breaks, fonts, and assets

  • Use break-inside: avoid on rows or cards that must stay together, and break-before/break-after for deliberate section breaks.
  • Prefer absolute or fully qualified asset URLs, or inline critical CSS. When using external fonts and images, wait for the relevant network activity or a selector before printing.
  • Use networkidle0 only when all requests are expected to settle. Analytics, long polling, or advertisements can prevent it; in those cases wait for a specific selector or a bounded delay.
  • For long documents, test headers, footers, table repetition, orphaned headings, and very large images at the target paper size.

Puppeteer’s documented API reference for Page.pdf() lists options such as format, landscape, margins, page ranges, scale, headers and footers, and whether to prefer CSS page size: Page.pdf() reference.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Render a URL instead of an HTML string

If the document is already exposed by an authenticated application route, use page.goto() and then print:

await page.goto('http://127.0.0.1:3000/invoice/123', {
  waitUntil: 'networkidle0'
});
await page.pdf({ path: 'invoice-123.pdf', format: 'A4', printBackground: true });

For protected pages, establish a session with Puppeteer’s cookie or login flow, or render the HTML directly as in the first example. Never put bearer tokens or private customer data in a URL that could be logged.

When PDFKit is a better fit

If your document is fundamentally a sequence of text, lines, images, and tables rather than a browser layout, PDFKit avoids a browser process. Its PDFDocument is a readable Node stream; it does not save automatically, and you finish it with doc.end(). The official guide is PDFKit Getting Started.

const PDFDocument = require('pdfkit');

app.get('/receipt/:id.pdf', (req, res) => {
  res.setHeader('Content-Type', 'application/pdf');
  res.setHeader('Content-Disposition', `attachment; filename="receipt-${req.params.id}.pdf"`);

  const doc = new PDFDocument({ size: 'A4', margin: 50 });
  doc.pipe(res);
  doc.fontSize(20).text(`Receipt ${req.params.id}`);
  doc.moveDown().fontSize(12).text('Thank you for your payment.');
  doc.end();
});

Choose the browser route when an existing HTML/CSS design, responsive layout, or web fonts are central. Choose PDFKit when deterministic drawing and streaming are more important than CSS fidelity. The cited documentation does not establish a universal speed or cost winner; measure your own workload.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Security, reliability, and operations

Validate and authorize before rendering

  • Authenticate the request and verify that the caller may access the invoice or report identified by the route parameter.
  • Load records server-side; do not trust prices, totals, or customer identities supplied by the browser.
  • Escape template values and avoid untrusted raw HTML. A PDF route still processes attacker-controlled input.
  • If you navigate to remote URLs, restrict destinations to prevent server-side request forgery and decide which cookies or headers may be forwarded.

Control resource use

Chromium startup, page count, fonts, images, and concurrent jobs affect memory and latency. Set request timeouts, cap input sizes, close pages and browsers in finally blocks, and put expensive jobs behind a queue when users do not need an immediate response. Observe failures and test the exact container or host that will run Chromium; sandbox permissions and available fonts vary by deployment.

Choose response behavior

Use inline when the browser should preview the PDF and attachment when it should download it. A buffer lets you set Content-Length; PDFKit can stream bytes as they are produced. For large browser-generated documents, writing to a temporary file and sending it can reduce peak application memory, provided temporary files are protected and removed.

Troubleshooting common failures

“Failed to lookup view” or a Jade/Pug compile error

Confirm app.set('views', ...), the filename, and the extension. A project configured for Pug will normally look for invoice.pug; a legacy Jade setup may require its older engine package. Read the line and column in the template error—indentation and missing delimiters are common causes.

The PDF is blank or missing images

Wait for a meaningful selector (for example, .invoice-total) or for required fonts and images to load before calling page.pdf(). Check that asset URLs are reachable from the server running Chromium and that CSS is not hiding the content in print media.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

The request hangs

Long-lived analytics or WebSocket requests can defeat networkidle0. Replace it with waitUntil: 'domcontentloaded' plus page.waitForSelector(), and enforce an application timeout. Always close the browser in a finally block.

Chromium will not launch in production

Verify that Puppeteer’s browser was installed or that executablePath points to an available Chromium binary. Containerized hosts may need the runtime libraries and a compatible sandbox configuration. Treat disabling the sandbox as a deployment security decision, not a copy-and-paste fix.

Layout differs from the web page

That is often print media, paper dimensions, or missing fonts rather than a template problem. Set the intended media type, use @page, include printBackground when needed, and embed or install the fonts you require.

Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

Or skip the browser setup

ScreenshotNeo provides a hosted screenshot and PDF endpoint, so your server does not need to install or supervise Chromium. It accepts a URL and can return a PDF; cookie and consent banners are accepted and 60+ known consent platforms, newsletter popups, and chat widgets are removed before capture. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed, and response headers identify the page verdict and billing result. The same service also offers an MCP server with take_screenshot, get_page_info, and capture_pdf tools for Claude, Cursor, and other MCP clients.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

For a public invoice or report URL, one GET request is enough (see the ScreenshotNeo API documentation):

curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp

In Node.js:

const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);

In Python:

import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)

Use an authenticated, non-public page only with a URL and access pattern you have secured; do not expose private report data through an open endpoint. A free plan includes 1,000 screenshots each month with no card; paid plans start at $5 for 3,000 shots, and every feature is included on every plan. Create a free ScreenshotNeo account.

FAQ

Can I keep a .jade extension?

Only if the installed legacy engine supports it. Jade is now Pug; standardize on Pug for new work and verify the migration for existing templates.

Does Express convert HTML to PDF?

No. Express renders the template to HTML. Puppeteer, PDFKit, or another PDF renderer performs the additional conversion.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

Should the route return a buffer or stream?

A Puppeteer PDF is commonly returned as a buffer. PDFKit exposes a readable stream that can pipe directly to the response; select the approach that fits your document and memory profile.

Why is there no universal performance recommendation?

Browser startup, document complexity, concurrency, fonts, and deployment resources vary. The official sources describe APIs and behavior, not a cross-workload benchmark, so measure with your own documents.

Frequently Asked Questions

Can I use Jade syntax with modern Express?

Use the Pug package and confirm your legacy template syntax; Jade was renamed to Pug, although older projects may still depend on the former package name.

What is the simplest way to return a generated PDF?

Render the view, call Puppeteer’s page.pdf(), set Content-Type to application/pdf, and send the returned bytes.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When should I avoid Puppeteer?

Use a direct library such as PDFKit when you need programmatic drawing and streaming rather than browser-accurate HTML/CSS layout.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.