Your options in Node.js
Node has three common ways to make a PDF. Each one is a good fit for some jobs, so pick by what your documents look like and who has to run the servers.
| Approach | Good for | What you take on |
|---|---|---|
| Puppeteer or Playwright | Full control over a headless Chrome you host yourself | A browser binary in every container, memory to watch, crashes and blank pages to debug under load |
| PDFKit or pdfmake | Simple layouts drawn in code, no HTML at all | Positioning every line by hand; tables that run over pages need your own logic |
| jsPDF | Small PDFs made in the browser | Limited CSS support; not built for long, multi-page business documents |
| An API such as CastPDF | HTML and CSS you already know, rendered by Chromium with print features added | A network call per PDF and a monthly plan above the free allowance |
If you already run Puppeteer happily and your documents fit on one page, you may not need anything else. Teams usually move to an API when the invoices get long: table headers stop repeating, rows split across pages, and the Chrome processes need their own on-call rota.
Your first PDF in about 15 lines
Create a free test key in the dashboard (it starts with cpdf_test_) and keep it in an environment variable. Then save this file as first.mjs and run node first.mjs. Test PDFs are free, never count towards your plan and carry a watermark.
import { writeFile } from 'node:fs/promises';
const res = await fetch('https://api.castpdf.com/v1/pdf', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CASTPDF_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
html: '<h1>Hello {{ name }}</h1><p>Your order ships today.</p>',
data: { name: 'Ada' },
}),
});
if (!res.ok) {
const { error } = await res.json();
throw new Error(`${error.code}: ${error.message}`);
}
await writeFile('first.pdf', Buffer.from(await res.arrayBuffer()));
console.log('pages:', res.headers.get('x-pages'));Because the request has a data object, the HTML is treated as a Liquid template and {{ name }} is filled in before rendering. Leave data out and the HTML is used exactly as you send it. The response body is the PDF itself, and the x-pages and x-document-id headers describe it.
Fill a saved template with JSON
For documents you send again and again, keep the design in a template and send only the data. Pick a starter in the dashboard (or create one with POST /v1/templates and a live key), copy its ID, and send the values. Ask for "response": "url" when you want a private download link instead of the file, for example to email it or store it in your database.
const res = await fetch('https://api.castpdf.com/v1/pdf', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CASTPDF_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': 'invoice-INV-2026-0142',
},
body: JSON.stringify({
template_id: process.env.INVOICE_TEMPLATE_ID,
data: {
invoice: { number: 'INV-2026-0142', issued: '2026-10-03', due: '2026-11-02' },
customer: { name: 'Northwind Traders' },
items: [
{ description: 'Design sprint', quantity: 3, unit_price: 950 },
{ description: 'Hosting, October', quantity: 1, unit_price: 49 },
],
},
filename: 'INV-2026-0142',
response: 'url',
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`${body.error.code}: ${body.error.message}`);
console.log(body.url, 'expires', body.expires_at);The data object replaces the template’s sample data as a whole, so send every field the template uses. The fields above are shortened; the invoice template lists them all.
Send the PDF to a browser from your server
Never call CastPDF from code that runs in the browser: anyone could read your key there. Call it from your server and pass the bytes on. This plain node:http server answers /receipt with a PDF the browser can open or save. The Express page shows the same in a route, and Next.js in a Route Handler.
import { createServer } from 'node:http';
createServer(async (req, res) => {
if (req.url !== '/receipt') {
res.writeHead(404).end();
return;
}
const pdf = await fetch('https://api.castpdf.com/v1/pdf', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.CASTPDF_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ template_id: process.env.RECEIPT_TEMPLATE_ID, filename: 'receipt' }),
signal: AbortSignal.timeout(60_000),
});
if (!pdf.ok) {
res.writeHead(502, { 'Content-Type': 'text/plain' }).end('Could not make the PDF');
return;
}
res.writeHead(200, { 'Content-Type': 'application/pdf', 'Content-Disposition': 'inline; filename="receipt.pdf"' });
res.end(Buffer.from(await pdf.arrayBuffer()));
}).listen(3000);Before you go live
- Keep the key in an environment variable or your secrets manager, and switch from the
cpdf_test_key to acpdf_live_key when you launch. Nothing else changes. - Give each request at least 60 seconds with
AbortSignal.timeout(60_000). A render may take up to 30 seconds, plus queue time when the service is busy. - Send an
Idempotency-Keythat names the document in your system, such as the invoice number. A retried request then returns the first PDF instead of making, and billing, a second one. - Retry
429after theRetry-Afterseconds and503with exponential backoff. Other4xxanswers describe a problem with the request, so retrying gives the same result. - Keep documents under 50 pages and 40 MB. Large images are the usual reason a PDF grows.
Errors you might see
Every error has the same JSON shape with a code, a readable message and a docs_url. These are the ones Node developers meet first. The errors page lists every code.
| Code | What it means | What to do |
|---|---|---|
invalid_api_key (401) | The key is missing, mistyped or revoked | Check process.env.CASTPDF_API_KEY is set where the code runs |
template_render_error (422) | A Liquid tag or filter failed | Read details.line and details.column, then fix the template or the data |
rate_limited (429) | Too many requests this minute (a test key allows 20) | Wait for Retry-After seconds, then retry |
plan_limit_reached (402) | The Free plan’s live PDFs are used up for the month | Upgrade, or keep building with the test key |
render_timeout (504) | Rendering took longer than 30 seconds | Look for slow external images or fonts, or split the document |
Next steps: the quickstart walks through the dashboard side, and the API reference documents every field. Using Python too? See the Python page.