How to Generate PDFs in Node.js with BladePDF
Build a production-ready HTML-to-PDF flow with the official BladePDF Node.js SDK, including EJS and React templates, local assets, Express, Fastify, Next.js, streaming, background jobs and verified webhooks.
On this page
Node.js PDF generation usually starts with a deceptively small requirement: turn an invoice, report or certificate into a downloadable PDF. The difficult part comes later—accurate CSS, web fonts, local images, request cancellation, large responses, background jobs and keeping a browser runtime healthy in production.
This guide builds that complete path with BladePDF for Node.js and the official @bladepdf/node SDK. You will render a real EJS invoice, safely include local assets, return PDFs from Express, Fastify and Next.js, render React on the server, choose the right delivery method, and verify background-render webhooks.
Render EJS, Handlebars, Pug, Nunjucks, React SSR or plain HTML in your application.
Your server sends HTML and approved assets; it does not install or launch Chrome.
Match the SDK method to an HTTP response, local file, queue or background workflow.
Short answer: install
@bladepdf/node, create one server-sideBladePdfclient, render your application template to an HTML string, then callfromHtml(html).render(). UserenderStream()for an HTTP download,renderToFile()for a durable local file, andstorePdf().submit()for a background render.
The architecture: your template stays in Node.js#
BladePDF does not replace EJS, React, Handlebars or your framework. Your application still loads data, applies authorization and renders the template. The SDK packages the resulting HTML with the local assets it is allowed to read; the managed API renders that document in Chromium and returns the PDF.
That boundary has two important consequences:
- Your application owns the HTML. BladePDF receives already-rendered markup or a BladePDF cloud template ID; it does not query your database or execute your EJS/React code.
- Your Node.js process does not own Chromium. There is no Puppeteer install, Chrome binary, browser sandbox,
/dev/shmsizing or pool lifecycle in your deployment.
The tradeoff is equally direct: rendering requires a network call, and the document HTML plus attached assets are sent to BladePDF. Keep secrets out of templates, scope API keys per environment, and use an in-process renderer when a document must never leave an isolated network.
Install the official Node.js SDK#
The current SDK requires Node.js 22 or newer, supports ESM and CommonJS, and has no runtime dependencies. Install it in the server-side application that will generate PDFs:
npm install @bladepdf/node
The package intentionally does not load .env files. Read the key with your application’s existing configuration system and pass it explicitly:
BLADEPDF_API_KEY=your-server-side-api-key
import { fileURLToPath } from 'node:url';
import { BladePdf } from '@bladepdf/node';
const publicRoot = fileURLToPath(new URL('../public', import.meta.url));
export function createBladePdf(): BladePdf {
const apiKey = process.env.BLADEPDF_API_KEY;
if (!apiKey) {
throw new Error('Missing BLADEPDF_API_KEY');
}
return new BladePdf({
apiKey,
timeoutMs: 60_000,
retries: 1,
retryDelayMs: 1_000,
assets: {
documentRoot: publicRoot,
searchRoots: [publicRoot],
},
});
}
The downloadable project keeps these resolved filesystem paths in a small shared module so every example uses the same roots.
Never import
@bladepdf/nodeinto browser code. The package and API key belong in a server process, API route, worker or server action. A client-side key would let anyone spend your quota and submit arbitrary documents as your account.
For CommonJS, use the same named export:
const { BladePdf } = require('@bladepdf/node');
const bladePdf = new BladePdf({
apiKey: process.env.BLADEPDF_API_KEY,
});
First PDF: render an HTML string#
Start with the smallest working render. render() buffers the completed PDF in memory, which is convenient for a short document or when another API expects a Buffer:
import { writeFile } from 'node:fs/promises';
import { BladePdf } from '@bladepdf/node';
const bladePdf = new BladePdf({
apiKey: process.env.BLADEPDF_API_KEY!,
});
const result = await bladePdf
.fromHtml(`
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title>Order ORD-1042</title></head>
<body><h1>Order ORD-1042</h1><p>Ready for dispatch.</p></body>
</html>
`)
.format('A4')
.printBackground()
.render();
await writeFile('order.pdf', result.pdf);
console.log(result.requestId);
result.pdf is a Node.js Buffer. The result also exposes save() and toBase64(), but avoid Base64 unless the next system explicitly requires it: encoding adds size and still holds the complete file in memory.
Build a real invoice with EJS#
Production documents usually combine a template with application data. The SDK stays template-engine agnostic, so render EJS normally and pass the resulting string to fromHtml().
import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import ejs from 'ejs';
export async function renderInvoiceHtml(invoice: Invoice): Promise<string> {
const template = await readFile(
join(process.cwd(), 'templates/invoice.ejs'),
'utf8',
);
const total = invoice.lines.reduce(
(sum, line) => sum + line.quantity * line.unitPrice,
0,
);
return ejs.render(template, { invoice, total });
}
The EJS document references a stylesheet and logo with ordinary root-relative URLs:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Invoice <%= invoice.number %></title>
<link rel="stylesheet" href="/styles/invoice.css" />
</head>
<body>
<header class="invoice-header">
<img src="/images/logo.svg" alt="Acme" />
<div><span>Invoice</span><h1><%= invoice.number %></h1></div>
</header>
<table>
<tbody>
<% invoice.lines.forEach((line) => { %>
<tr>
<td><%= line.description %></td>
<td><%= line.quantity %></td>
<td>$<%= (line.quantity * line.unitPrice).toFixed(2) %></td>
</tr>
<% }) %>
</tbody>
</table>
</body>
</html>
Then apply PDF options and stream the completed response atomically to a file:
const html = await renderInvoiceHtml(exampleInvoice);
const result = await createBladePdf()
.fromHtml(html, { baseDirectory: templatesRoot })
.format('A4')
.margins({ top: 16, right: 14, bottom: 18, left: 14, unit: 'mm' })
.printBackground()
.emulateMedia('print')
.waitForFonts()
.reference(exampleInvoice.number)
.templateName('Node.js invoice')
.renderToFile(`output/${exampleInvoice.number}.pdf`);
console.log(result.requestId);
reference() gives the render your own searchable business identifier; templateName() supplies a human-readable label in BladePDF. Neither changes the document content.
A real, verified output#
The invoice below was generated from the downloadable TypeScript project with the public @bladepdf/node package.
Use the exact project behind this guide
It includes EJS, Express, Fastify, Next.js-style route handlers, React SSR, background webhooks, TypeScript checks and local asset fixtures.
Download the complete Node.js exampleTypeScript source, template, assets and package lock Open the generated PDFHow local images, CSS and fonts reach Chromium#
A remote browser cannot read /srv/app/public/images/logo.svg from your server. Turning that path into a public URL works only when the renderer can access that URL, the asset is deployed, authentication permits it, and the hostname resolves from the render environment.
The Node.js SDK instead has an opt-in asset resolver. It scans supported HTML and CSS references, reads only files inside configured roots, attaches them to the multipart render request, and rewrites the document to internal asset URLs before rendering.
const bladePdf = new BladePdf({
apiKey,
assets: {
documentRoot: '/srv/app/public',
searchRoots: ['/srv/app/public', '/srv/app/storage/pdf-assets'],
localHosts: ['app.internal', 'localhost'],
},
});
Automatic filesystem access is disabled until you configure at least one root. Canonical files must stay inside those roots; traversal and symlink escapes are rejected. This keeps a malicious or mistaken template reference such as ../../.env from becoming a render attachment.
The resolver understands common HTML references (src, href, poster, srcset, data-src), inline styles, <style>, CSS url() and nested @import. It intentionally does not inspect JavaScript imports, runtime fetch() calls or dependencies hidden inside an SVG file. Attach generated or exceptional assets explicitly:
const render = bladePdf
.fromHtml(reportHtml, { baseDirectory: '/srv/app/templates/reports' })
.assetData(chartPngBuffer, {
target: 'charts/revenue.png',
mimeType: 'image/png',
})
.assetFile('/srv/tenants/acme/signature.svg', {
target: 'tenant/signature.svg',
});
const result = await render.render();
Use narrow, deliberate roots. Pointing documentRoot at the whole repository weakens the boundary and makes accidental file inclusion harder to review.
Return a PDF from Express without buffering it#
For a direct browser download, a stream avoids holding the whole PDF in application memory. renderStream() returns a one-shot Node.js Readable; always consume it or destroy it.
import { pipeline } from 'node:stream/promises';
import type { NextFunction, Request, Response } from 'express';
export async function downloadInvoice(
_request: Request,
response: Response,
next: NextFunction,
): Promise<void> {
try {
const html = await renderInvoiceHtml(exampleInvoice);
const result = await createBladePdf()
.fromHtml(html, { baseDirectory: templatesRoot })
.format('A4')
.printBackground()
.reference(exampleInvoice.number)
.renderStream();
response.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${exampleInvoice.number}.pdf"`,
});
await pipeline(result.stream, response);
} catch (error) {
next(error);
}
}
Do not call response.send(result.stream): depending on the framework, that may serialize the stream object or bypass backpressure. Node’s pipeline() forwards stream failures and cleans up the pipe.
If the client disconnects frequently, connect the request’s abort or close lifecycle to an AbortController and pass its signal to renderStream({ signal }) so abandoned requests do not continue consuming render capacity.
Generate PDFs in Fastify#
Fastify can send the SDK’s Readable directly. Set the MIME type and a safe, application-controlled filename before returning it:
export function registerPdfRoutes(app: FastifyInstance): void {
app.get('/invoices/:id.pdf', async (_request, reply) => {
const html = await renderInvoiceHtml(exampleInvoice);
const result = await createBladePdf()
.fromHtml(html, { baseDirectory: templatesRoot })
.format('A4')
.printBackground()
.renderStream();
return reply
.type('application/pdf')
.header(
'Content-Disposition',
`attachment; filename="${exampleInvoice.number}.pdf"`,
)
.send(result.stream);
});
}
In a real route, load the invoice by request.params.id only after authorizing the current user. PDF endpoints often expose more customer and billing data than ordinary screens, so treat them as data-export endpoints rather than harmless formatting routes.
Generate PDFs in a Next.js Route Handler#
@bladepdf/node is server-only, so a Next.js route must use the Node.js runtime. Buffer delivery is the most portable bridge from the SDK’s Buffer to the Web Response API:
export const runtime = 'nodejs';
export async function POST(request: Request): Promise<Response> {
const invoice = (await request.json()) as Invoice;
const html = await renderInvoiceHtml(invoice);
const result = await createBladePdf()
.fromHtml(html, { baseDirectory: templatesRoot })
.format('A4')
.printBackground()
.reference(invoice.number)
.render();
return new Response(new Uint8Array(result.pdf), {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${invoice.number}.pdf"`,
},
});
}
Validate the request body and resolve the real invoice server-side instead of trusting prices or customer data sent by the browser. Also remember that buffering counts against both your function memory and any response-size limits imposed by the hosting platform. For large reports, submit a background render and return a job identifier instead.
Render React to PDF on the server#
BladePDF renders HTML, not a React component tree. Use React’s server renderer first, then submit the static markup exactly like EJS output:
import { renderToStaticMarkup } from 'react-dom/server';
function InvoiceDocument({ invoice }: { invoice: Invoice }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<link rel="stylesheet" href="/styles/invoice.css" />
</head>
<body>
<h1>Invoice {invoice.number}</h1>
<p>Bill to: {invoice.customer.name}</p>
</body>
</html>
);
}
const html = `<!doctype html>${renderToStaticMarkup(
<InvoiceDocument invoice={invoice} />,
)}`;
const result = await bladePdf.fromHtml(html).format('A4').render();
Use renderToStaticMarkup() for a document that does not need hydration. If the document requires client-side chart rendering or layout JavaScript, include the browser script intentionally and pair it with waitForFunction() or an appropriate waitUntil() condition. Server-rendering all deterministic content is usually faster and produces fewer timing failures.
What about Handlebars, Pug and Nunjucks?#
They follow the same two-step contract:
| Template source | Render inside Node.js | Send to BladePDF |
|---|---|---|
| EJS | ejs.render(template, data) |
fromHtml(html) |
| Handlebars | compiledTemplate(data) |
fromHtml(html) |
| Pug | pug.renderFile(path, data) |
fromHtml(html) |
| Nunjucks | nunjucks.render(path, data) |
fromHtml(html) |
| React SSR | renderToStaticMarkup(element) |
fromHtml(html) |
This separation is useful: template rendering remains easy to unit-test without an API request, while one smaller integration test verifies the complete HTML-to-PDF path.
Choose the right delivery method#
The PDF content is identical; the difference is how it travels back to your application and how much work remains inside the original request.
| Method | Returns | Best fit | Memory and lifecycle |
|---|---|---|---|
render() |
RenderResult with Buffer |
Short PDFs, email attachments, object-storage SDKs | Buffers the complete PDF |
renderStream() |
One-shot Node Readable |
Express/Fastify HTTP download | Streams with backpressure; must be consumed |
renderToFile(path) |
File result | CLI, worker or durable local artifact | Atomic temporary-file replacement |
storePdf().submit() |
Background submission | Slow reports, batch work, serverless limits | Returns before rendering finishes; requires webhook/polling |
renderToFile() does more than writeFile(await render().pdf): it streams through a temporary sibling file and replaces the destination only after the complete response arrives. That prevents a partial PDF from appearing at the final path after a network failure.
Every delivery method accepts an AbortSignal, so cancellation can be consistent across a web request, worker timeout and application shutdown.
Control layout and page readiness#
Browser-quality output still requires print-aware HTML. Start with explicit paper size, margins and print background, then control the moment at which the page is ready to print:
const result = await bladePdf
.fromHtml(reportHtml)
.paperSize({ width: 210, height: 297, unit: 'mm' })
.margins({ top: 15, right: 12, bottom: 15, left: 12, unit: 'mm' })
.printBackground()
.emulateMedia('print')
.waitUntil('networkidle0')
.waitForFonts()
.headerHtml('<div class="pdf-header">Quarterly report</div>')
.footerHtml('<div class="pdf-footer"><span class="pageNumber"></span></div>')
.render();
Use @page and print media rules in the document itself for repeatable layout:
@page {
size: A4;
margin: 16mm 14mm 18mm;
}
@media print {
.screen-only { display: none !important; }
.avoid-break { break-inside: avoid; }
table thead { display: table-header-group; }
}
Avoid adding long arbitrary delays “just in case.” Prefer a precise readiness condition for dynamic content and keep external network dependencies out of the render when they can be attached as local assets.
Move long renders into the background#
An HTTP request is a poor place to wait for a 200-page report. Background submission accepts the job quickly, stores the final PDF and reports completion through a signed webhook:
const submission = await createBladePdf()
.fromHtml(html, { baseDirectory: templatesRoot })
.reference(invoice.number)
.storePdf()
.webhook({
url: 'https://billing.example.com/webhooks/bladepdf',
secret: process.env.BLADEPDF_WEBHOOK_SECRET!,
events: ['pdf.rendered', 'pdf.failed'],
})
.submit();
console.log(submission.requestId);
storePdf() is required for background renders because there is no open response in which to return the PDF bytes. Persist the request ID with your own report record so retries and support investigations can connect application state to the render.
Verify the webhook before parsing JSON#
Signature verification must receive the exact raw request bytes. In Express, register the raw-body route before a global express.json() middleware:
import { verifyWebhookSignature } from '@bladepdf/node';
import express from 'express';
app.post(
'/webhooks/bladepdf',
express.raw({ type: 'application/json' }),
(request, response) => {
const valid = verifyWebhookSignature({
rawBody: request.body,
timestamp: request.header('bladepdf-timestamp'),
signature: request.header('bladepdf-signature'),
secret: process.env.BLADEPDF_WEBHOOK_SECRET!,
});
if (!valid) {
response.sendStatus(401);
return;
}
const event = JSON.parse(request.body.toString('utf8'));
queueWebhook(event);
response.sendStatus(204);
},
);
Return quickly after durable acceptance. Process slow business logic in your own queue, and make the handler idempotent because any reliable webhook delivery system may retry an event.
Handle timeouts, retries and errors explicitly#
Client configuration and caller cancellation solve different problems. timeoutMs limits each SDK request. An AbortSignal lets your application cancel the whole operation based on its own deadline or shutdown lifecycle.
import { BladePdfError, RenderFailedError } from '@bladepdf/node';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 45_000);
try {
const result = await render.render({ signal: controller.signal });
return result.pdf;
} catch (error) {
if (error instanceof RenderFailedError) {
console.error('BladePDF render failed', {
status: error.statusCode,
requestId: error.requestId,
});
} else if (error instanceof BladePdfError) {
console.error('BladePDF SDK error', error.message);
}
throw error;
} finally {
clearTimeout(timeout);
}
The SDK retries network failures and transient HTTP 429, 502, 503 and 504 responses; it respects Retry-After. It does not retry validation and authentication failures that will not improve on another attempt. Keep the retry count bounded, use your own stable reference, and log the BladePDF request ID without logging document HTML or customer data.
BladePDF or local Puppeteer/Playwright?#
There is no universally correct Node.js PDF renderer. Choose the operational boundary that matches the application.
Use Puppeteer or Playwright locally when you need offline rendering, control of the exact browser binary and flags, browser automation beyond printing, or a security policy that forbids sending the document to a managed API. In return, your team owns Chrome installation, sandboxing, fonts, process cleanup, memory limits, queue capacity and browser upgrades.
Use BladePDF when the output needs Chromium fidelity but the application should not operate Chromium. It is especially useful for ordinary backend services and serverless deployments that already produce complete HTML, need predictable Buffer/stream/file APIs, or would otherwise build a dedicated browser worker service.
If your templates only use a conservative subset of HTML/CSS and browser-level layout is unnecessary, a non-browser PDF library can be smaller and fully in-process. Test your hardest real document before committing to an architecture—not only a “Hello world” page.
Production checklist#
- Run
@bladepdf/nodeonly in server-side Node.js 22+ code. - Store the API key in the deployment secret manager; never in the repository or client bundle.
- Reuse a configured
BladePdfclient instead of scattering keys and timeouts across routes. - Validate and authorize document requests before loading sensitive data.
- Render template HTML locally and unit-test it without making an API call.
- Configure the narrowest asset roots that cover the document.
- Prefer attached local assets over fragile private or expiring URLs.
- Use a stream for direct downloads and a background job for slow or large reports.
- Add a caller deadline with
AbortSignaland keep SDK retries bounded. - Verify webhook signatures against raw bytes and process events idempotently.
- Log your reference and BladePDF request ID, not document contents.
- Keep a visual regression fixture for the most complex production template.
The working companion project covers each integration shape in this guide. Download it, set BLADEPDF_API_KEY only in your shell or secret manager, and run npm run typecheck, npm test and npm run generate before adapting it to your application.
Continue reading
Generate your first production PDF from Node.js.
Keep EJS, React or your current template engine. BladePDF handles Chromium, asset transport and PDF delivery while your application keeps ownership of its HTML and data.
Was this article helpful?
Your answer helps shape future technical guides.