Building a PDF feature?
Save this to your Work Desktop.

All Tutorials
JavaScriptTutorial

HTML to PDF in Express (Node.js)

Generate PDFs from any Express route in a few lines of fetch.

01. Install

# Use built-in fetch (Node 18+) or install undici / node-fetch # npm install dotenv (if you don't already have it) # Add to .env: # PDFMYHTML_API_KEY=pmh_live_xxxxxxxxxxxxxxxxxxxx

02. Basic call

// routes/invoice.js import express from 'express'; const router = express.Router(); router.get('/invoice/:id.pdf', async (req, res) => { const html = renderInvoiceHtml(req.params.id); // your template fn const apiRes = await fetch('https://api.pdfmyhtml.com/v1/html-to-pdf', { method: 'POST', headers: { 'X-API-Key': process.env.PDFMYHTML_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ html, wait: true }), }); const { download_url } = await apiRes.json(); const pdfBuffer = Buffer.from(await (await fetch(download_url)).arrayBuffer()); res.set('Content-Type', 'application/pdf'); res.send(pdfBuffer); }); export default router;

03. Error handling

// Production-ready: timeout, status checks, error logging import express from 'express'; const router = express.Router(); router.get('/invoice/:id.pdf', async (req, res) => { try { const apiRes = await fetch('https://api.pdfmyhtml.com/v1/html-to-pdf', { method: 'POST', headers: { 'X-API-Key': process.env.PDFMYHTML_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ html: renderInvoiceHtml(req.params.id), wait: true }), signal: AbortSignal.timeout(30_000), }); if (!apiRes.ok) { console.error('pdfmyhtml HTTP', apiRes.status, await apiRes.text()); return res.status(500).send('Could not generate PDF'); } const data = await apiRes.json(); if (data.status !== 'COMPLETED') { return res.status(503).send('PDF still processing — try async'); } const pdfBuffer = Buffer.from(await (await fetch(data.download_url)).arrayBuffer()); res.set('Content-Type', 'application/pdf'); res.send(pdfBuffer); } catch (err) { console.error('pdfmyhtml error', err); res.status(500).send('PDF service unavailable'); } }); export default router;

04. Async pattern (high volume)

// Async pattern with BullMQ: enqueue → worker polls → write to S3 // Avoids holding the Express request open. import { Queue, Worker } from 'bullmq'; const pdfQueue = new Queue('pdf', { connection: { host: 'redis' } }); // In your route: router.post('/invoice/:id/pdf', async (req, res) => { const apiRes = await fetch('https://api.pdfmyhtml.com/v1/html-to-pdf', { method: 'POST', headers: { 'X-API-Key': process.env.PDFMYHTML_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ html: renderInvoiceHtml(req.params.id), wait: false }), }); const { job_id } = await apiRes.json(); await pdfQueue.add('poll', { invoiceId: req.params.id, jobId: job_id }); res.json({ accepted: true, jobId: job_id }); }); // Worker: new Worker('pdf', async (job) => { while (true) { await new Promise((r) => setTimeout(r, 2000)); const r = await fetch(`https://api.pdfmyhtml.com/v1/jobs/${job.data.jobId}`, { headers: { 'X-API-Key': process.env.PDFMYHTML_API_KEY }, }); const data = await r.json(); if (data.status === 'COMPLETED') { const pdf = await (await fetch(data.download_url)).arrayBuffer(); // upload to S3, store reference, send email, etc. return; } if (data.status === 'FAILED') throw new Error(data.error_message); } }, { connection: { host: 'redis' } });

05. Real-world example

// Stripe webhook → invoice PDF → email via Resend import express from 'express'; import { Resend } from 'resend'; import Stripe from 'stripe'; const app = express(); const stripe = new Stripe(process.env.STRIPE_SECRET); const resend = new Resend(process.env.RESEND_API_KEY); app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => { const event = stripe.webhooks.constructEvent( req.body, req.headers['stripe-signature'], process.env.STRIPE_WEBHOOK_SECRET ); if (event.type === 'invoice.paid') { const inv = event.data.object; const html = renderInvoiceHtml(inv); // your Handlebars/EJS render const apiRes = await fetch('https://api.pdfmyhtml.com/v1/html-to-pdf', { method: 'POST', headers: { 'X-API-Key': process.env.PDFMYHTML_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ html, wait: true }), }); const { download_url } = await apiRes.json(); const pdf = await (await fetch(download_url)).arrayBuffer(); await resend.emails.send({ from: 'billing@yourapp.com', to: inv.customer_email, subject: `Invoice ${inv.number}`, html: 'Your invoice is attached.', attachments: [{ filename: `invoice-${inv.number}.pdf`, content: Buffer.from(pdf) }], }); } res.json({ received: true }); });

Common gotchas

  • 01.Node 18+ has built-in `fetch`. If you're still on Node 16, install `undici` or `node-fetch` and import explicitly — no top-level polyfill needed.
  • 02.Don't set `Content-Length` manually when piping the PDF buffer — Express + Node's default streaming handles it correctly. Manual `Content-Length` will truncate large PDFs.
  • 03.For high-volume use, the sync 25s wait pattern will exhaust your event loop under load. Use BullMQ / Inngest / Temporal to enqueue and process asynchronously.
  • 04.`AbortSignal.timeout()` is Node 18+ only. On older runtimes use `AbortController` with `setTimeout` to clear it.

Going deeper? Read the full guide on cost trade-offs vs self-hosted Headless Chrome.