Why Node.js developers need PDF generation
Almost every SaaS product eventually needs to produce PDF documents. Invoices sent after a payment succeeds. Reports generated on a schedule. Contracts signed and countersigned. Certificates awarded after a course is completed. Shipping labels attached to fulfillment events.
The traditional approach — spinning up a headless browser like Puppeteer, feeding it HTML, and calling page.pdf() — works, but comes with a cost: a 300MB Chromium binary, 200MB of RAM per browser instance, and render times measured in seconds. For data-driven documents where the layout is fixed and only the data changes, there is a faster path.
Typsetter is a PDF generation API. You send JSON data to a REST endpoint and receive a PDF binary back in ~340ms. No browser, no binary dependencies, no infrastructure. This tutorial covers everything you need to integrate it into a Node.js project.
1 Install the SDK
The official Node.js SDK wraps the Typsetter REST API with TypeScript types, automatic retries, and streaming support. It works in Node.js 18+ with native fetch.
npm install typsetter-node
If you prefer not to use the SDK, every example in this tutorial also shows the equivalent raw fetch call. The SDK is a convenience layer — the REST API is the source of truth.
2 Get your API key
Sign up at typsetter.dev/register (free, no credit card). Once logged in, go to Settings → API Keys and create a new key. You will see a key that starts with ts_live_sk_.
Security
Store your API key in an environment variable (TYPSETTER_API_KEY). Never commit it to source control. The SDK reads from this variable automatically if no key is passed to the constructor.
TYPSETTER_API_KEY=ts_live_sk_your_key_here
3 Generate your first invoice PDF
This example renders an invoice using the built-in invoice-professional template. You pass your data as JSON and receive a PDF buffer back.
Using the SDK
import { Typsetter } from 'typsetter-node';
import { writeFile } from 'node:fs/promises';
const client = new Typsetter('ts_live_sk_your_key_here');
const pdf = await client.render({
template: 'invoice-professional',
format: 'pdf',
data: {
company_name: 'Acme Corp',
company_address: '123 Main St, New York, NY 10001',
client_name: 'Wayne Enterprises',
client_address: '1007 Mountain Dr, Gotham City',
invoice_number: 'INV-2026-0042',
date: '2026-03-23',
due_date: '2026-04-22',
currency: 'USD',
items: [
{ description: 'Web application development', qty: 80, unit_price: 150 },
{ description: 'UI/UX design', qty: 24, unit_price: 130 },
{ description: 'QA testing', qty: 16, unit_price: 110 },
],
tax_rate: 0.1,
notes: 'Payment via bank transfer. Net 30 days.',
}
});
await writeFile('invoice.pdf', Buffer.from(pdf));
console.log('Invoice saved to invoice.pdf');
Using raw fetch
If you prefer no dependencies beyond Node.js itself, the REST API is straightforward.
import { writeFile } from 'node:fs/promises';
const API_KEY = process.env.TYPSETTER_API_KEY;
const response = await fetch('https://api.typsetter.dev/v1/render', {
method: 'POST',
headers: {
'Authorization': `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
template: 'invoice-professional',
format: 'pdf',
data: {
company_name: 'Acme Corp',
client_name: 'Wayne Enterprises',
invoice_number: 'INV-2026-0042',
date: '2026-03-23',
due_date: '2026-04-22',
currency: 'USD',
items: [
{ description: 'Web application development', qty: 80, unit_price: 150 },
{ description: 'UI/UX design', qty: 24, unit_price: 130 },
],
tax_rate: 0.1,
}
})
});
if (!response.ok) {
const err = await response.json();
throw new Error(`Typsetter API error: ${err.message}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
await writeFile('invoice.pdf', buffer);
console.log('Invoice saved to invoice.pdf');
Tip
Run the script with node generate-invoice.mjs. The file opens in any PDF reader. Average render time is ~340ms, regardless of whether you use the SDK or raw fetch.
4 Generate a report with dynamic data
Reports typically involve tables with variable-length data, subtotals, and sometimes descriptions that span multiple paragraphs. The Typsetter API handles all of this — you pass arrays and the template iterates over them.
import { Typsetter } from 'typsetter-node';
import { writeFile } from 'node:fs/promises';
const client = new Typsetter(); // reads TYPSETTER_API_KEY from env
// Imagine this comes from your database
const salesData = await fetchSalesFromDB();
const pdf = await client.render({
template: 'report-monthly',
format: 'pdf',
data: {
report_title: 'Q1 2026 Sales Report',
period: 'January — March 2026',
generated_at: new Date().toISOString(),
summary: {
total_revenue: 284500,
total_orders: 1847,
avg_order_value: 154,
growth_pct: 12.3,
},
// Tables: pass an array, the template loops over it
top_products: [
{ name: 'Enterprise Plan', units: 312, revenue: 156000 },
{ name: 'Growth Plan', units: 845, revenue: 84500 },
{ name: 'Starter Plan', units: 690, revenue: 34500 },
],
monthly_breakdown: [
{ month: 'January', revenue: 87200, orders: 573 },
{ month: 'February', revenue: 92800, orders: 614 },
{ month: 'March', revenue: 104500, orders: 660 },
],
notes: 'Revenue up 12.3% QoQ driven by Enterprise plan adoption.',
}
});
await writeFile('q1-report.pdf', Buffer.from(pdf));
console.log('Report generated successfully');
The template handles the layout: headers, table formatting, page breaks, footers with page numbers. Your code only needs to provide the data. If the table grows to 200 rows, the template paginates automatically.
5 Batch generation from an array
Need to generate 500 certificates or 1,000 invoices? You do not need to loop and call the API 1,000 times. The batch endpoint accepts an array of data objects and returns a ZIP file containing every generated PDF.
import { Typsetter } from 'typsetter-node';
import { writeFile, readFile } from 'node:fs/promises';
const client = new Typsetter();
// Your data — from a CSV, database query, or any source
const employees = [
{ name: 'Alice Chen', department: 'Engineering', employee_id: 'EMP-001' },
{ name: 'Bob Martinez', department: 'Design', employee_id: 'EMP-002' },
{ name: 'Carol Johnson', department: 'Marketing', employee_id: 'EMP-003' },
// ... hundreds more
];
const batch = await client.batchRender({
template: 'certificate-completion',
format: 'pdf',
filename_pattern: 'cert-{{employee_id}}', // each PDF is named cert-EMP-001.pdf, etc.
items: employees.map(emp => ({
recipient_name: emp.name,
department: emp.department,
course_name: 'Annual Safety Training 2026',
completion_date: '2026-03-23',
certificate_id: `CERT-${emp.employee_id}`,
})),
});
console.log(`Batch ID: ${batch.id}`);
console.log(`Status: ${batch.status}`); // 'processing' or 'completed'
console.log(`Documents: ${batch.total_documents}`);
// For small batches, the ZIP is returned immediately.
// For large batches (100+), use a webhook to get notified (see section below).
if (batch.download_url) {
const zip = await fetch(batch.download_url).then(r => r.arrayBuffer());
await writeFile('certificates.zip', Buffer.from(zip));
console.log('Saved certificates.zip');
}
Loading data from a CSV file
You can also read data from a CSV and feed it directly into batch generation.
import { Typsetter } from 'typsetter-node';
import { readFile } from 'node:fs/promises';
const client = new Typsetter();
// Simple CSV parser (use 'csv-parse' for production)
const csv = await readFile('invoices.csv', 'utf-8');
const [headerLine, ...rows] = csv.trim().split('\n');
const headers = headerLine.split(',');
const items = rows.map(row => {
const values = row.split(',');
return Object.fromEntries(headers.map((h, i) => [h.trim(), values[i].trim()]));
});
const batch = await client.batchRender({
template: 'invoice-professional',
format: 'pdf',
filename_pattern: 'invoice-{{invoice_number}}',
items,
});
console.log(`Queued ${batch.total_documents} invoices`);
6 Error handling and best practices
Production code needs to handle failures gracefully. Here are the patterns that matter.
import { Typsetter, TypsetterError } from 'typsetter-node';
const client = new Typsetter({
apiKey: process.env.TYPSETTER_API_KEY,
timeout: 30000, // 30s timeout (default: 60s)
retries: 2, // retry on 5xx and network errors
});
try {
const pdf = await client.render({
template: 'invoice-professional',
format: 'pdf',
data: { /* ... */ }
});
} catch (err) {
if (err instanceof TypsetterError) {
switch (err.status) {
case 400:
console.error('Invalid data:', err.message);
// Missing required field or wrong data type
break;
case 401:
console.error('Invalid API key');
break;
case 404:
console.error('Template not found:', err.message);
break;
case 429:
console.error('Rate limit exceeded. Retry after:', err.retryAfter);
break;
default:
console.error(`API error ${err.status}:`, err.message);
}
} else {
console.error('Network error:', err.message);
}
}
Best practices
- Reuse the client instance. Create one
Typsetter instance and share it across your application. The SDK manages connection pooling internally.
- Use environment variables for the API key. Never hardcode it.
- Set a reasonable timeout. Single documents render in under 1 second. A 10-second timeout catches network issues without making your users wait forever.
- Stream large PDFs instead of buffering them in memory. The SDK supports
client.renderStream() which returns a ReadableStream.
- Validate data before sending. If a required template field is missing, the API returns a 400 error. Catch this early in your code.
7 Webhook integration
For batch jobs or asynchronous generation, you can register a webhook URL that Typsetter will call when a batch is complete. This is better than polling.
import { Typsetter } from 'typsetter-node';
const client = new Typsetter();
const batch = await client.batchRender({
template: 'invoice-professional',
format: 'pdf',
items: invoiceDataArray,
webhook_url: 'https://your-app.com/api/webhooks/typsetter',
webhook_secret: 'whsec_your_signing_secret',
});
console.log(`Batch ${batch.id} queued — webhook will fire on completion`);
Webhook handler (Express)
import express from 'express';
import { Typsetter } from 'typsetter-node';
const app = express();
app.post('/api/webhooks/typsetter',
express.raw({ type: 'application/json' }),
(req, res) => {
const signature = req.headers['x-typsetter-signature'];
const isValid = Typsetter.verifyWebhook(
req.body,
signature,
'whsec_your_signing_secret'
);
if (!isValid) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
if (event.type === 'batch.completed') {
console.log(`Batch ${event.batch_id} done!`);
console.log(`Download: ${event.download_url}`);
console.log(`Total documents: ${event.total_documents}`);
// Trigger your downstream logic: email, S3 upload, etc.
}
res.status(200).send('ok');
}
);
app.listen(3000);
8 Comparison: Typsetter SDK vs raw Puppeteer
To make the difference concrete, here is the same task — generating an invoice PDF — implemented with both approaches side by side.
Puppeteer approach
import puppeteer from 'puppeteer';
import { writeFile, readFile } from 'node:fs/promises';
// 1. You need an HTML template file with placeholders
let html = await readFile('./templates/invoice.html', 'utf-8');
// 2. Manual string replacement (or use a template engine)
html = html
.replace('{{company_name}}', 'Acme Corp')
.replace('{{client_name}}', 'Wayne Enterprises')
.replace('{{invoice_number}}', 'INV-2026-0042');
// ... repeat for every field
// ... manually build HTML table rows for line items
// ... manually calculate subtotals, tax, total
// 3. Launch a 300MB headless browser
const browser = await puppeteer.launch({
args: ['--no-sandbox', '--disable-setuid-sandbox'],
headless: 'new',
});
// 4. Create page, set content, wait for rendering
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
// 5. Generate PDF
const pdf = await page.pdf({
format: 'A4',
printBackground: true,
margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
});
await browser.close();
await writeFile('invoice.pdf', pdf);
// Total: ~4200ms cold, ~800ms warm. You also need to maintain:
// - The HTML/CSS template
// - A browser pool for production
// - Chromium binary updates
// - Memory management (100-300MB per instance)
Typsetter approach
import { Typsetter } from 'typsetter-node';
import { writeFile } from 'node:fs/promises';
const client = new Typsetter();
const pdf = await client.render({
template: 'invoice-professional',
format: 'pdf',
data: {
company_name: 'Acme Corp',
client_name: 'Wayne Enterprises',
invoice_number: 'INV-2026-0042',
items: [
{ description: 'Web development', qty: 80, unit_price: 150 },
],
}
});
await writeFile('invoice.pdf', Buffer.from(pdf));
// Total: ~340ms. No browser. No template files. No infra.
The difference
Puppeteer requires you to maintain HTML/CSS templates, manage a headless browser, and handle memory. Typsetter moves all of that to a managed API. You send data, you get a PDF. The template is managed through a visual editor in the dashboard — no HTML files to maintain in your repo.
9 Integrating with Express / Fastify / Next.js
Here is a practical pattern: an Express endpoint that generates and returns a PDF on demand.
import express from 'express';
import { Typsetter } from 'typsetter-node';
const app = express();
const typsetter = new Typsetter();
app.get('/invoices/:id/pdf', async (req, res) => {
const invoice = await db.invoices.findById(req.params.id);
if (!invoice) return res.status(404).json({ error: 'Not found' });
const pdf = await typsetter.render({
template: 'invoice-professional',
format: 'pdf',
data: {
company_name: invoice.company_name,
client_name: invoice.client_name,
invoice_number: invoice.number,
date: invoice.date,
items: invoice.line_items,
tax_rate: invoice.tax_rate,
}
});
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${invoice.number}.pdf"`);
res.send(Buffer.from(pdf));
});
app.listen(3000);
This endpoint responds in under 500ms for a typical invoice. The PDF is generated on-the-fly — no temp files, no cleanup needed.
10 Conclusion
PDF generation in Node.js does not have to involve headless browsers, 300MB binaries, or memory management headaches. The Typsetter API reduces the problem to its essence: send JSON data, receive a PDF document.
Here is what we covered:
- SDK installation — one
npm install, zero binary dependencies
- Single document generation — invoices, reports, certificates in ~340ms
- Batch generation — hundreds of documents in a single API call
- Error handling — typed errors, retries, and timeouts
- Webhooks — async notifications for batch completion
- Framework integration — Express endpoints that return PDFs on demand
The free tier includes 100 PDFs per month with no credit card required. That is enough to build and test your integration before committing to a paid plan.
Start generating PDFs in 5 minutes
Free API key. No credit card. 100 PDFs/month on the free plan.