- HTML to PDF APIConvert HTML and URLs into PDFsExtract PDF Form Data APIEasily extract data from PDFsWatermark PDF APIAdd custom watermarks to PDFsProtect PDF APISecure your PDFs with passwordCompress PDF APIReduce file size without losing qualityFlatten PDF APIFlatten PDFs to make form permanentDigital Signature APISend documents for signing
- API DocumentationAPI DocumentationFull REST API referenceNodeJS SDKClient library for Node.jsJava SDKClient library for JavaC# SDKClient library for C#PHP SDKClient library for PHPPython SDKClient library for Python
- Pricing
- Contact Us

HTML to PDF in Node.js: From a Script to an Express Route
Start with a ten-line script and end with an Express route that serves PDFs. Page setup, headers, template literals, JavaScript content and errors, every sample run before publishing.
Most Node.js developers meet HTML to PDF through Puppeteer and then meet its deployment problems on the first serverless function. This tutorial keeps the browser out of your bundle. It goes from one URL conversion to an Express server that serves invoices, by way of page setup, running headers, templates built from objects, pages that render with JavaScript, and the errors you need to handle.
Chrome runs inside the HTML to PDF API on PDFGate's side, so your process only makes requests with the SDK or fetch. Each listing on this page ran against the API before publication, and the Express routes were started and called with curl.
Key takeaways
- Two calls do the work:
generatePdfrenders,getFilereturns aBuffer. No Puppeteer download, so it runs on Lambda, Vercel and the edge. - Options use the API's own camelCase names. Set
pageSizeTypeexplicitly, the default is Ledger, and passmarginas an object of CSS lengths. - If the page draws after load, make it append a marker element when done and pass that selector as
waitForSelector. - In Express, create one client at module level, catch rejections with
next(error), and send theBufferor hand back a pre-signed link.
The options in Node.js
Node.js is where most teams first meet HTML to PDF, because Puppeteer makes it look easy. The usual choices:
- Puppeteer or Playwright. Real Chrome output and a pleasant API. The cost shows up at deploy time. The browser download is a few hundred megabytes, each render takes real memory, and on Lambda, Vercel or Cloud Run you end up fighting package size limits and cold starts.
- PDFKit and pdfmake. Solid libraries, but they draw PDFs from a layout description of their own. They do not take HTML, so every template is written twice.
- html-pdf and similar packages. Built on PhantomJS, which has been unmaintained since 2018. Avoid.
- A conversion API. Nothing to bundle beyond an HTTP client, so it deploys wherever
fetchworks, serverless and edge included. Each document is a round trip, and the service is a subscription.
The rest of this page uses the API. To weigh that against running Chrome in your own functions, see the Puppeteer comparison.
Install the Node.js SDK
The pdfgate package on npm has no runtime dependencies, ships both ESM and CommonJS builds, includes TypeScript declarations, and runs on Node.js 18 or newer. The listings are ESM with top-level await. From CommonJS, require("pdfgate") and wrap the calls in an async function.
1npm install pdfgateKeys are created in the dashboard. The prefix picks the environment, test_ for the sandbox with its free, watermarked output and live_ for production. The listings read process.env.PDFGATE_API_KEY.
Convert a URL to PDF
Rendering is generatePdf then getFile. The first resolves to a document object with an id, the second resolves to a Buffer holding that document.
1import { writeFile } from "node:fs/promises";2import PdfGate, { PageSizeType } from "pdfgate";34const client = new PdfGate(process.env.PDFGATE_API_KEY);56const page = await client.generatePdf({7 url: "https://en.wikipedia.org/wiki/Portable_Document_Format",8 pageSizeType: PageSizeType.a4,9});1011const pdf = await client.getFile({ documentId: page.id });12await writeFile("wikipedia.pdf", pdf);1314console.log(`URL -> ${page.id} (${page.size} bytes)`);- Methods take one options object whose property names are the API's own, so the API reference is also the SDK reference.
- A URL has to be reachable from the public internet.
localhostis rejected with a400before any rendering starts. - With no
pageSizeTypethe result is Ledger. ThePageSizeTypeenum uses lowercase members such asPageSizeType.a4, and the bare string"a4"is accepted as well. Custom sizes go inwidthandheightas pixels. page.sizereports the PDF's byte length. The 23 page Wikipedia article is around 1.4 MB, comfortably held in aBuffer.- The client keeps no connection state, so one instance at module level serves the whole process.
Convert an HTML string
For HTML your own code produces, set html and leave out url. A template literal holds a complete page with its <style> block, and public stylesheets, images and fonts linked from it are fetched during the render.
1const invoiceHtml = `2<!DOCTYPE html>3<html>4<head>5 <meta charset="utf-8">6 <style>7 body { font-family: Helvetica, Arial, sans-serif; color: #1f2933; }8 h1 { color: #0055d4; }9 table { width: 100%; border-collapse: collapse; margin-top: 24px; }10 th { background: #0055d4; color: white; text-align: left; padding: 8px; }11 td { padding: 8px; border-bottom: 1px solid #e4e7eb; }12 .total { text-align: right; font-size: 18px; margin-top: 16px; }13 </style>14</head>15<body>16 <h1>Invoice #2026-0142</h1>17 <p>Billed to Acme GmbH, 21 October 2026</p>18 <table>19 <tr><th>Item</th><th>Qty</th><th>Price</th></tr>20 <tr><td>API plan, Business</td><td>1</td><td>EUR 79.00</td></tr>21 <tr><td>Extra documents</td><td>2,000</td><td>EUR 20.00</td></tr>22 </table>23 <p class="total">Total EUR 99.00</p>24</body>25</html>26`;2728const invoice = await client.generatePdf({29 html: invoiceHtml,30 pageSizeType: PageSizeType.a4,31 margin: { top: "20mm", bottom: "20mm", left: "15mm", right: "15mm" },32 printBackground: true,33});3435await writeFile("invoice.pdf", await client.getFile({ documentId: invoice.id }));36console.log(`HTML -> ${invoice.id} (${invoice.size} bytes)`);marginis an object withtop,bottom,leftandrightin CSS units. Omit a side to keep its default.- Set
printBackgroundor Chrome's print mode drops background colours and your table header prints white. - A wide table wants
orientation: "landscape". A page that only looks right on screen wantsemulateMediaType: "screen", which swaps in the screen stylesheet. - The template literal that holds static HTML here becomes a real template two sections down, with the same syntax.
All layout options are described in page size, margins and orientation.
Headers, footers and page numbers
A running header or footer is its own HTML string in header or footer, printed on every page. The renderer fills elements carrying the classes date, title, url, pageNumber and totalPages. Without margin.top for a header or margin.bottom for a footer there is no room to draw it, and the request fails.
1const report = await client.generatePdf({2 url: "https://en.wikipedia.org/wiki/Portable_Document_Format",3 pageSizeType: PageSizeType.a4,4 header: `5 <div style="width: 100%; font-size: 10px; padding: 0 15mm; color: #52606d;">6 Portable Document Format, exported <span class="date"></span>7 </div>`,8 footer: `9 <div style="width: 100%; font-size: 10px; padding: 0 15mm; text-align: right; color: #52606d;">10 Page <span class="pageNumber"></span> of <span class="totalPages"></span>11 </div>`,12 margin: { top: "25mm", bottom: "20mm" },13});1415await writeFile("header-footer.pdf", await client.getFile({ documentId: report.id }));16console.log(`Header/footer -> ${report.id}`);- State a
font-sizeinline. The default in header templates is tiny. - Header fragments are rendered as a separate document, out of reach of the page's stylesheet, so styles must be inline.
- The fragment is as wide as the paper, so give it padding equal to the page margins.
Headers with a logo, a tinted background or a web font need extra care, and headers and footers walks through each case.
Templates with your own data
An invoice, a contract or a report starts as objects in your code. A template literal with map and join for the repeating rows is enough for a small document and needs no dependency. Intl.NumberFormat handles the money formatting.
1const order = {2 number: "2026-0142",3 customer: "Acme GmbH",4 lines: [5 { description: "API plan, Business", quantity: 1, unitPrice: 79.0 },6 { description: "Extra documents", quantity: 2000, unitPrice: 0.01 },7 ],8};910const money = new Intl.NumberFormat("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 });11const total = order.lines.reduce((sum, line) => sum + line.quantity * line.unitPrice, 0);1213const html = `14<h1>Invoice #${order.number}</h1>15<p>Billed to ${order.customer}</p>16<table>17 <tr><th>Item</th><th>Qty</th><th>Amount</th></tr>18 ${order.lines19 .map(20 (line) =>21 `<tr><td>${line.description}</td><td>${line.quantity.toLocaleString("en-US")}</td>` +22 `<td>EUR ${money.format(line.quantity * line.unitPrice)}</td></tr>`,23 )24 .join("\n ")}25</table>26<p>Total EUR ${money.format(total)}</p>27`;2829const templated = await client.generatePdf({ html, pageSizeType: PageSizeType.a4 });3031await writeFile("template.pdf", await client.getFile({ documentId: templated.id }));32console.log(`Template -> ${templated.id}`);- Anything that came from a user must be escaped before it goes into the string. The
escape-htmlpackage is the usual one-liner. A customer name containing<script>is otherwise rendered as markup. - For larger documents, use the templating you already have. Handlebars, EJS and Pug all render to a string, and in a React project
renderToStaticMarkupfromreact-dom/serverturns a component into the HTML to send. - Pass an explicit locale to
Intl.NumberFormat, as the sample does. The server's default locale is rarely the reader's.
Pages that render with JavaScript
A chart drawn after load, or data pulled with fetch(), is absent from the PDF unless the renderer waits for it. Add a marker element when the page is done and pass its selector as waitForSelector. The listing simulates slow content with a timer.
1const chartHtml = `2<h1>Sales report</h1>3<div id="chart">Loading…</div>4<script>5 // Stands in for a chart library or a fetch() that finishes later6 setTimeout(() => {7 document.getElementById("chart").textContent = "Q4 revenue: EUR 1.2M";8 document.body.insertAdjacentHTML("beforeend", '<div id="chart-ready"></div>');9 }, 800);10</script>11`;1213const chart = await client.generatePdf({ html: chartHtml, waitForSelector: "#chart-ready" });1415await writeFile("wait.pdf", await client.getFile({ documentId: chart.id }));16console.log(`Wait -> ${chart.id}`);- If the selector is still missing after 30 seconds the API returns
422, never a partial PDF. waitForNetworkIdlecovers pages you cannot change, anddelayis a fixed pause in milliseconds for the last resort.javascriptinjects a script before capture. It is a string, so it cannot close over variables from your Node code.
The right choice per page type is discussed in waiting for dynamic content.
Error handling
A failed call rejects with a PdfGateApiError carrying statusCode, responseBody and a message that quotes the API. Version 1.3.0 does not export the class, so test error.name rather than instanceof. The listing fails on purpose with a loopback URL.
1try {2 await client.generatePdf({ url: "http://localhost:3000/report" });3} catch (error) {4 if (error.name === "PdfGateApiError") {5 console.log(`API error ${error.statusCode}: ${error.message}`);6 } else {7 throw error;8 }9}The statuses that matter in practice:
400is a parameter the API would not accept, named in the message. The local URL here, or a malformedpageRanges.401is a missing or invalid key.422is a page that loaded but could not be rendered, typically awaitForSelectortimeout.429is more concurrent renders than the plan permits. Wait a moment and retry.
Timeouts and connection errors also reject with PdfGateApiError, but without a statusCode. Check for the status before choosing to retry.
In Express, Next.js or serverless
Create the client once at module level. The first route sends the Buffer with the PDF content type and a download header. The second requests a temporary link through preSignedUrlExpiresIn and returns JSON, so the file never transits your server.
1import express from "express";2import PdfGate, { PageSizeType } from "pdfgate";34const app = express();5const client = new PdfGate(process.env.PDFGATE_API_KEY);67// Returns the PDF directly8app.get("/invoices/:number/pdf", async (req, res, next) => {9 try {10 const html = `<h1>Invoice #${req.params.number}</h1><p>Generated on ${new Date().toDateString()}</p>`;1112 const document = await client.generatePdf({ html, pageSizeType: PageSizeType.a4 });13 const pdf = await client.getFile({ documentId: document.id });1415 res16 .type("application/pdf")17 .attachment(`invoice-${req.params.number}.pdf`)18 .send(pdf);19 } catch (error) {20 next(error);21 }22});2324// Returns a temporary download link instead of the bytes25app.post("/invoices/:number/pdf-link", async (req, res, next) => {26 try {27 const document = await client.generatePdf({28 html: `<h1>Invoice #${req.params.number}</h1>`,29 preSignedUrlExpiresIn: 3600,30 });3132 res.json({ id: document.id, fileUrl: document.fileUrl, expiresAt: document.expiresAt });33 } catch (error) {34 next(error);35 }36});3738app.listen(3000, () => console.log("Listening on http://localhost:3000"));- Express 4 ignores rejected promises in handlers, hence the
tryblock andnext(error). Express 5 forwards them automatically. - A Next.js route handler returns
new Response(pdf, { headers: { "Content-Type": "application/pdf" } }). The same calls run unchanged on Lambda or Vercel, because there is no browser to bundle. - A pre-signed link is only issued when the account has file storage enabled, otherwise the API answers
422. Lifetimes range from 60 seconds to 24 hours. - A render takes a few seconds. For batches,
Promise.allis fine as long as the number in flight stays within the plan's concurrency limit.
Without the SDK (fetch)
The SDK is a small wrapper around POST /generate/pdf, which takes JSON and returns the PDF. Node.js 18 ships fetch, so no package is required.
1import { writeFile } from "node:fs/promises";23const response = await fetch("https://api.pdfgate.com/generate/pdf", {4 method: "POST",5 headers: {6 Authorization: `Bearer ${process.env.PDFGATE_API_KEY}`,7 "Content-Type": "application/json",8 },9 body: JSON.stringify({10 html: "<h1>Hello from fetch</h1>",11 pageSizeType: "a4",12 margin: { top: "20mm", bottom: "20mm" },13 }),14});1516if (response.status !== 201) {17 console.log(await response.json());18 process.exit(1);19}2021await writeFile("http.pdf", Buffer.from(await response.arrayBuffer()));22console.log("fetch -> http.pdf");- Look for status
201. SettingjsonResponsetotruereturns the document record in place of the file. - With a
test_key the host must beapi-sandbox.pdfgate.com. The SDK derives the host from the prefix,fetchdoes not. arrayBuffer()loads the whole PDF into memory. For large output, piperesponse.bodyinto a write stream instead.
Where to go next
- Forms. With
enableFormFieldsthe inputs in your HTML become editable PDF fields. The fillable fields guide covers the input types, andflattenPdflocks them afterwards. - Pages behind auth. Forward the session cookie or token through
httpHeaders, or useauthenticationfor basic auth, see authenticated pages. - Modals and cookie bars.
clickSelectorChainSetuptakes a list of selectors to click before capture, covered in removing cookie banners. - Fonts. Only Liberation and colour emoji are installed, so brand typefaces arrive as web fonts, the subject of custom fonts and images.
- Same ground, other runtimes. There are C#, Java, Python and PHP editions of this page.
- The complete option list is the HTML to PDF API reference, and the methods this page skipped are in the Node.js SDK documentation.
Frequently asked questions
Which Node.js versions does the SDK support?
Node.js 18 and later. The package ships ESM and CommonJS builds with TypeScript types and has no runtime dependencies.
How do I add page numbers to a PDF generated from Node.js?
Pass header and footer HTML with the pageNumber and totalPages CSS classes, and set margin.top and margin.bottom so there is room to draw them. The renderer fills the classes in on every page.
Can I generate the HTML with React or a template engine?
Yes. renderToStaticMarkup from react-dom/server, Handlebars, EJS and Pug all produce a string you send as html. Escape any user input, for example with the escape-html package, before it goes into a template literal.
What happens when a request fails?
The promise rejects with a PdfGateApiError whose statusCode is the HTTP status and whose message quotes the API. The class is not exported in version 1.3.0, so check error.name. A 400 means an invalid parameter, 401 a bad key, 422 a page that could not be rendered, and 429 more concurrent conversions than the plan allows.
The options this tutorial skipped are on the HTML to PDF API page.