I spent three days trying to generate PDFs in a Node.js app.
The first approach was puppeteer. Installing it added 300MB to my container because it bundles Chromium. Fine. Then it worked locally but crashed in production with cryptic errors about missing libraries. Fixed those. Then the memory usage: each PDF generation spun up a browser instance, consumed 200MB, and occasionally just... didn't release it. I woke up to a dead server that had run out of RAM generating invoices.
The second approach was a "lightweight" PDF library. No browser dependency, small footprint. Perfect. Except it couldn't render flexbox. Or modern CSS. The invoice templates I'd designed in CSS had to be completely rewritten using absolute positioning. It looked like 2005.
Then I found PDF APIs. Send HTML, get a PDF. Someone else deals with headless Chrome, memory management, and rendering complexity. I get a document.
That three-day project took 20 minutes.
The Simplest Version
const response = await fetch('https://api.apiverve.com/v1/htmltopdf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.APIVERVE_KEY
},
body: JSON.stringify({
html: '<h1>Hello World</h1><p>This is a PDF.</p>'
})
});
const { data } = await response.json();
console.log(data.downloadURL);
// Returns a signed URL to download the generated PDF
That's it. Your HTML goes in, a PDF URL comes out. The URL is signed and temporary—download it, store it, or redirect users to it directly.
Why Not Just Use Browser Print-to-PDF?
I get this question a lot. "Can't I just use window.print() and tell users to save as PDF?"
Sure, if you want:
- Different results on every browser and OS
- Users who don't understand "Destination: Save as PDF"
- Broken layouts from print stylesheets you didn't write
- No way to automate anything
Server-side PDF generation gives you:
- Identical output every time
- Automated batch generation
- No user interaction required
- Consistent branding regardless of client
For one-off documents a user wants to print, window.print() is fine. For anything automated—invoices, receipts, reports, contracts—you need server-side generation.
Building a Real Invoice System
Let me show you a complete invoice implementation. This is what I wish I'd had when I started.
function generateInvoiceHTML(invoice) {
const lineItems = invoice.items.map(item => `
<tr>
<td>${item.description}</td>
<td class="right">${item.quantity}</td>
<td class="right">$${item.unitPrice.toFixed(2)}</td>
<td class="right">$${(item.quantity * item.unitPrice).toFixed(2)}</td>
</tr>
`).join('');
return `
<!DOCTYPE html>
<html>
<head>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: #1a1a1a;
line-height: 1.5;
padding: 40px;
max-width: 800px;
}
.header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 40px;
padding-bottom: 20px;
border-bottom: 2px solid #e5e5e5;
}
.logo {
font-size: 28px;
font-weight: 700;
color: #2563eb;
}
.invoice-title {
text-align: right;
}
.invoice-title h1 {
margin: 0;
font-size: 32px;
color: #64748b;
font-weight: 300;
letter-spacing: 2px;
}
.invoice-number {
font-size: 14px;
color: #64748b;
}
.parties {
display: flex;
gap: 60px;
margin-bottom: 40px;
}
.party-box h3 {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: #64748b;
margin: 0 0 8px 0;
}
.party-box p {
margin: 0;
font-size: 14px;
}
.party-box .name {
font-weight: 600;
font-size: 16px;
}
.meta {
display: flex;
gap: 40px;
margin-bottom: 40px;
padding: 20px;
background: #f8fafc;
border-radius: 8px;
}
.meta-item {
font-size: 13px;
}
.meta-item .label {
color: #64748b;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.meta-item .value {
font-weight: 600;
margin-top: 4px;
}
table {
width: 100%;
border-collapse: collapse;
margin-bottom: 24px;
}
th {
text-align: left;
padding: 12px 8px;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #64748b;
border-bottom: 2px solid #e5e5e5;
}
td {
padding: 16px 8px;
border-bottom: 1px solid #f1f5f9;
font-size: 14px;
}
.right { text-align: right; }
.totals {
width: 280px;
margin-left: auto;
}
.totals tr td {
border: none;
padding: 8px;
}
.totals .label { color: #64748b; }
.totals .value { text-align: right; font-weight: 500; }
.totals .total-row td {
padding-top: 16px;
border-top: 2px solid #e5e5e5;
font-size: 18px;
}
.totals .total-row .value {
color: #2563eb;
font-weight: 700;
}
.notes {
margin-top: 60px;
padding-top: 20px;
border-top: 1px solid #e5e5e5;
font-size: 12px;
color: #64748b;
}
.payment-info {
margin-top: 24px;
padding: 16px;
background: #f0fdf4;
border-radius: 8px;
font-size: 13px;
}
.payment-info strong { color: #166534; }
</style>
</head>
<body>
<div class="header">
<div class="logo">${invoice.company.name}</div>
<div class="invoice-title">
<h1>INVOICE</h1>
<div class="invoice-number">#${invoice.number}</div>
</div>
</div>
<div class="parties">
<div class="party-box">
<h3>From</h3>
<p class="name">${invoice.company.name}</p>
<p>${invoice.company.address}</p>
<p>${invoice.company.city}, ${invoice.company.state} ${invoice.company.zip}</p>
<p>${invoice.company.email}</p>
</div>
<div class="party-box">
<h3>Bill To</h3>
<p class="name">${invoice.customer.name}</p>
<p>${invoice.customer.address}</p>
<p>${invoice.customer.city}, ${invoice.customer.state} ${invoice.customer.zip}</p>
<p>${invoice.customer.email}</p>
</div>
</div>
<div class="meta">
<div class="meta-item">
<div class="label">Invoice Date</div>
<div class="value">${invoice.date}</div>
</div>
<div class="meta-item">
<div class="label">Due Date</div>
<div class="value">${invoice.dueDate}</div>
</div>
<div class="meta-item">
<div class="label">Payment Terms</div>
<div class="value">${invoice.terms || 'Net 30'}</div>
</div>
</div>
<table>
<thead>
<tr>
<th>Description</th>
<th class="right">Qty</th>
<th class="right">Rate</th>
<th class="right">Amount</th>
</tr>
</thead>
<tbody>
${lineItems}
</tbody>
</table>
<table class="totals">
<tr>
<td class="label">Subtotal</td>
<td class="value">$${invoice.subtotal.toFixed(2)}</td>
</tr>
${invoice.discount ? `
<tr>
<td class="label">Discount (${invoice.discountPercent}%)</td>
<td class="value">-$${invoice.discount.toFixed(2)}</td>
</tr>
` : ''}
<tr>
<td class="label">Tax (${invoice.taxRate}%)</td>
<td class="value">$${invoice.tax.toFixed(2)}</td>
</tr>
<tr class="total-row">
<td class="label">Total Due</td>
<td class="value">$${invoice.total.toFixed(2)}</td>
</tr>
</table>
${invoice.paymentInfo ? `
<div class="payment-info">
<strong>Payment Instructions:</strong><br>
${invoice.paymentInfo}
</div>
` : ''}
<div class="notes">
<strong>Notes:</strong><br>
${invoice.notes || 'Thank you for your business. Payment is due within ' + (invoice.terms || '30 days') + '.'}
</div>
</body>
</html>
`;
}
That's a production-quality invoice template. Modern styling, responsive layout, professional appearance. Now use it:
async function createInvoicePDF(invoiceData) {
const html = generateInvoiceHTML(invoiceData);
const response = await fetch('https://api.apiverve.com/v1/htmltopdf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.APIVERVE_KEY
},
body: JSON.stringify({
html,
marginTop: 0.5,
marginRight: 0.5,
marginBottom: 0.5,
marginLeft: 0.5
})
});
const { data } = await response.json();
return data.downloadURL;
}
// Usage
const pdfUrl = await createInvoicePDF({
company: {
name: 'Acme Development LLC',
address: '100 Innovation Drive',
city: 'San Francisco',
state: 'CA',
zip: '94102',
email: 'billing@acmedev.io'
},
customer: {
name: 'Widgets Inc.',
address: '500 Commerce Street',
city: 'Austin',
state: 'TX',
zip: '78701',
email: 'accounts@widgets.co'
},
number: 'INV-2026-0042',
date: 'January 15, 2026',
dueDate: 'February 14, 2026',
terms: 'Net 30',
items: [
{ description: 'Web Application Development', quantity: 80, unitPrice: 150 },
{ description: 'API Integration', quantity: 24, unitPrice: 175 },
{ description: 'Database Optimization', quantity: 16, unitPrice: 200 },
{ description: 'Monthly Hosting', quantity: 1, unitPrice: 299 }
],
subtotal: 19499,
taxRate: 0,
tax: 0,
total: 19499,
paymentInfo: 'Bank: First National Bank | Account: 1234567890 | Routing: 021000021',
notes: 'Thank you for your business. Please reference invoice number with payment.'
});
Markdown to PDF: The Simpler Option
For text-heavy documents where you don't need custom styling, Markdown is faster:
const markdown = `
# Q4 2025 Performance Report
## Executive Summary
Strong quarter across all metrics. Revenue exceeded targets by 12%, user growth
hit 34% YoY, and churn decreased to all-time low.
## Key Metrics
| Metric | Target | Actual | Status |
|--------|--------|--------|--------|
| Revenue | $2.4M | $2.69M | ✓ |
| New Users | 15,000 | 18,420 | ✓ |
| Churn Rate | 2.5% | 1.8% | ✓ |
| NPS Score | 45 | 52 | ✓ |
## Highlights
### Product
- Launched v3.0 with new dashboard
- API response time improved 40%
- Mobile app downloads up 67%
### Growth
- Enterprise segment grew 45%
- Partnership with BigCorp signed
- International expansion to 3 new markets
### Operations
- Support ticket volume down 23%
- Infrastructure costs reduced 15%
- Zero downtime incidents
## Recommendations for Q1
1. **Double down on enterprise** - Highest margin segment
2. **Launch referral program** - Capitalize on high NPS
3. **Expand API offerings** - Customer requests indicate demand
## Conclusion
Exceptional quarter. Team executed well across all departments. Well-positioned
for aggressive 2026 targets.
`;
const response = await fetch('https://api.apiverve.com/v1/markdowntopdf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.APIVERVE_KEY
},
body: JSON.stringify({ markdown })
});
const { data } = await response.json();
// Clean, formatted PDF with proper headings and tables
Tables, lists, headers, emphasis—all rendered properly. For internal reports, documentation, or any structured text, Markdown-to-PDF is the path of least resistance.
Receipts and Thermal Printer Style
E-commerce, POS systems, and order confirmations need a different format:
function generateReceiptHTML(receipt) {
return `
<!DOCTYPE html>
<html>
<head>
<style>
body {
font-family: 'Courier New', monospace;
font-size: 12px;
width: 280px;
padding: 16px;
line-height: 1.4;
}
.center { text-align: center; }
.bold { font-weight: bold; }
.divider {
border: none;
border-top: 1px dashed #000;
margin: 12px 0;
}
.row {
display: flex;
justify-content: space-between;
}
.item-row {
display: flex;
justify-content: space-between;
margin: 4px 0;
}
.item-name { max-width: 180px; }
.total-line {
font-weight: bold;
font-size: 14px;
border-top: 2px solid #000;
padding-top: 8px;
margin-top: 8px;
}
.footer {
margin-top: 20px;
text-align: center;
font-size: 10px;
color: #666;
}
</style>
</head>
<body>
<div class="center bold" style="font-size: 16px;">
${receipt.storeName}
</div>
<div class="center">${receipt.storeAddress}</div>
<div class="center">${receipt.storePhone}</div>
<hr class="divider">
<div class="center">
Receipt #${receipt.receiptNumber}<br>
${receipt.date} at ${receipt.time}<br>
Cashier: ${receipt.cashier || 'Self'}
</div>
<hr class="divider">
${receipt.items.map(item => `
<div class="item-row">
<span class="item-name">${item.name}${item.quantity > 1 ? ` x${item.quantity}` : ''}</span>
<span>$${(item.price * item.quantity).toFixed(2)}</span>
</div>
`).join('')}
<hr class="divider">
<div class="row">
<span>Subtotal</span>
<span>$${receipt.subtotal.toFixed(2)}</span>
</div>
<div class="row">
<span>Tax</span>
<span>$${receipt.tax.toFixed(2)}</span>
</div>
<div class="row total-line">
<span>TOTAL</span>
<span>$${receipt.total.toFixed(2)}</span>
</div>
<hr class="divider">
<div class="row">
<span>${receipt.paymentMethod}</span>
<span>$${receipt.amountPaid.toFixed(2)}</span>
</div>
${receipt.change > 0 ? `
<div class="row">
<span>Change</span>
<span>$${receipt.change.toFixed(2)}</span>
</div>
` : ''}
<div class="footer">
Thank you for shopping with us!<br>
${receipt.returnPolicy || 'Returns accepted within 30 days with receipt.'}
</div>
</body>
</html>
`;
}
Generate with minimal margins for that authentic thermal printer look:
const { data } = await fetch('https://api.apiverve.com/v1/htmltopdf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.APIVERVE_KEY
},
body: JSON.stringify({
html: generateReceiptHTML(receiptData),
marginTop: 0.2,
marginRight: 0.2,
marginBottom: 0.2,
marginLeft: 0.2
})
}).then(r => r.json());
What to Do with the PDF
The API returns a downloadURL. Here's what you can do with it:
Redirect user to download:
// Express.js
app.get('/invoice/:id/download', async (req, res) => {
const invoice = await getInvoice(req.params.id);
const pdfUrl = await createInvoicePDF(invoice);
res.redirect(pdfUrl);
});
Email as attachment:
const pdfUrl = await createInvoicePDF(invoice);
const pdfResponse = await fetch(pdfUrl);
const pdfBuffer = Buffer.from(await pdfResponse.arrayBuffer());
await sendEmail({
to: invoice.customer.email,
subject: `Invoice ${invoice.number} from ${invoice.company.name}`,
html: '<p>Please find your invoice attached.</p>',
attachments: [{
filename: `invoice-${invoice.number}.pdf`,
content: pdfBuffer
}]
});
Store in cloud storage:
const pdfUrl = await createInvoicePDF(invoice);
const pdfResponse = await fetch(pdfUrl);
const pdfBuffer = Buffer.from(await pdfResponse.arrayBuffer());
const s3Key = `invoices/${invoice.number}.pdf`;
await s3.putObject({
Bucket: 'my-invoices',
Key: s3Key,
Body: pdfBuffer,
ContentType: 'application/pdf'
}).promise();
Stream directly to user:
app.get('/invoice/:id/pdf', async (req, res) => {
const invoice = await getInvoice(req.params.id);
const pdfUrl = await createInvoicePDF(invoice);
const pdfResponse = await fetch(pdfUrl);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="invoice-${invoice.number}.pdf"`);
// Stream the response (Node 18+)
const reader = pdfResponse.body.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
res.write(value);
}
res.end();
});
Batch Generation
Monthly invoices, bulk reports, mass mailers—sometimes you need lots of PDFs.
async function generateBatchPDFs(items, generator, concurrency = 5) {
const results = [];
// Process in chunks to avoid overwhelming the API
for (let i = 0; i < items.length; i += concurrency) {
const chunk = items.slice(i, i + concurrency);
const chunkResults = await Promise.all(
chunk.map(async item => {
try {
const html = generator(item);
const response = await fetch('https://api.apiverve.com/v1/htmltopdf', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': process.env.APIVERVE_KEY
},
body: JSON.stringify({ html })
});
const { data } = await response.json();
return { id: item.id, success: true, url: data.downloadURL };
} catch (err) {
return { id: item.id, success: false, error: err.message };
}
})
);
results.push(...chunkResults);
// Brief pause between chunks
if (i + concurrency < items.length) {
await new Promise(r => setTimeout(r, 200));
}
}
return results;
}
// Generate all monthly invoices
const invoices = await getMonthlyInvoices('2026-01');
const pdfs = await generateBatchPDFs(invoices, generateInvoiceHTML);
console.log(`Generated ${pdfs.filter(p => p.success).length} PDFs`);
Common Mistakes
Forgetting that CSS is different for print. Web CSS and print CSS aren't identical. Things to watch:
-
box-shadowdoesn't print well - Gradients can be problematic
- Some fonts don't embed properly
Not handling page breaks. Long content needs explicit page breaks:
.page-break { page-break-before: always; }
.avoid-break { page-break-inside: avoid; }
Ignoring timeouts. PDF generation is slower than typical API calls. Set appropriate timeouts (15-30 seconds minimum).
Assuming URLs will work. If your HTML references external images via URL, make sure they're publicly accessible. Or better, embed images as base64:
const imageBase64 = Buffer.from(imageBuffer).toString('base64');
const imgTag = `<img src="data:image/png;base64,${imageBase64}">`;
Not testing on actual printers. Your PDF will look great on screen. Will it print correctly? Test it. Margins matter.
The Economics
PDF generation costs 1 credit per document.
On the Starter plan ({{plan.starter.price}}/month, {{plan.starter.calls}} credits), that's thousands of PDFs per month. If you're sending monthly invoices, that's affordable pricing for professional document generation.
Compare to:
- Running your own puppeteer infrastructure: $50-200/month for dedicated servers with enough RAM
- Headless Chrome services: $0.01-0.05 per PDF, similar pricing but you manage nothing
For most businesses generating fewer than 50,000 documents/month, the API is cheaper and infinitely simpler.
PDF generation used to be a weekend project that turned into a two-week infrastructure nightmare. Headless Chrome. Memory management. Rendering inconsistencies. Dependencies that broke on updates.
Now it's an API call. HTML in, PDF out. Someone else handles the browser, the rendering, the infrastructure. You focus on the content.
The HTML to PDF and Markdown to PDF APIs use the same authentication as everything else. Combine them with QR Code Generator for scannable invoices or Barcode Generator for shipping labels.
Get your API key and start generating documents that look professional without the infrastructure headaches.
Originally published at APIVerve Blog
Top comments (0)