If you've ever had to generate a PDF from HTML in Node.js, you know the story.
You reach for Puppeteer. The docs look reasonable. Then, three hours later, you're debugging a segfault in a Docker container because Chromium refuses to run without --no-sandbox, your CSS isn't rendering the same as it does in the browser, and your server's memory has ballooned to 800MB handling a single request.
This is not a Puppeteer bash post. Puppeteer is a solid project. The problem is that running a full browser in production is genuinely hard — and for most apps that need to generate PDFs, it's the wrong tool for the job.
Here's a better approach.
The Setup: One Package, One API Key
npm install renderpdfs
Sign up at renderpdfs.com and grab your API key. Free tier gives you 100 PDFs/month, no credit card.
import RenderPDFs from 'renderpdfs';
const client = new RenderPDFs('rpdf_your_key');
Basic HTML to PDF
const pdf = await client.generate({
html: '<h1>Hello, world</h1><p>This is a PDF.</p>',
});
import { writeFileSync } from 'fs';
writeFileSync('output.pdf', pdf);
That's it. No browser launch, no page lifecycle, no teardown.
A Real-World Example: Invoice PDF from a Template String
import express from 'express';
import RenderPDFs from 'renderpdfs';
const app = express();
const client = new RenderPDFs(process.env.RENDERPDF_API_KEY);
app.post('/invoices/:id/pdf', async (req, res) => {
const invoice = await db.invoices.findById(req.params.id);
const html = `
<!DOCTYPE html>
<html>
<head>
<style>
body { font-family: sans-serif; padding: 40px; color: #111; }
h1 { font-size: 24px; margin-bottom: 4px; }
.meta { color: #555; font-size: 14px; margin-bottom: 32px; }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; border-bottom: 1px solid #ddd; padding: 8px 0; }
td { padding: 8px 0; border-bottom: 1px solid #f0f0f0; }
.total { font-weight: bold; font-size: 16px; margin-top: 24px; text-align: right; }
</style>
</head>
<body>
<h1>Invoice #${invoice.number}</h1>
<div class="meta">Issued: ${invoice.date} · Due: ${invoice.dueDate}</div>
<table>
<thead><tr><th>Item</th><th>Qty</th><th>Price</th></tr></thead>
<tbody>
${invoice.items.map(item => `
<tr>
<td>${item.name}</td>
<td>${item.qty}</td>
<td>$${item.price}</td>
</tr>
`).join('')}
</tbody>
</table>
<div class="total">Total: $${invoice.total}</div>
</body>
</html>
`;
const pdf = await client.generate({ html });
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="invoice-${invoice.number}.pdf"`);
res.send(pdf);
});
Want to Store the PDF and Get a URL Back?
const { url } = await client.generate({ html, store: true });
await db.invoices.update(invoice.id, { pdfUrl: url });
await emailService.send({
to: customer.email,
subject: `Invoice #${invoice.number}`,
body: `Download your invoice: ${url}`,
});
Why Not Just Keep Using Puppeteer?
- Memory: each Chromium instance is ~150–250MB
- Cold starts: on serverless (Vercel, Lambda), launching a browser is slow and often fails
- Docker complexity: you need the right Debian deps, flags, and user permissions
- Maintenance: keeping Puppeteer and Chromium versions in sync is its own ongoing task
An API approach offloads all of that. Your server stays lean. The PDF renders consistently.
Getting Started
npm install renderpdfs
Free plan at renderpdfs.com: 100 PDFs/month, no credit card. Full API docs at renderpdfs.com/docs.
Top comments (0)