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_xxxxxxxxxxxxxxxxxxxx02. 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.